#!/usr/bin/env python3 # SPDX-License-Identifier: GPL-2.0 """ flamegraph.py - create flame graphs from perf samples using perf python module """ from __future__ import annotations import argparse import hashlib import json import os import re import subprocess import sys import urllib.request from typing import Dict, Optional, Union import perf MINIMAL_HTML = """
""" class Node: """A node in the flame graph tree.""" def __init__(self, name: str, libtype: str): self.name = name self.libtype = libtype self.value: int = 0 self.children: dict[str, Node] = {} def to_json(self) -> Dict[str, Union[str, int, list[Dict]]]: """Convert the node to a JSON-serializable dictionary.""" return { "n": self.name, "l": self.libtype, "v": self.value, "c": [x.to_json() for x in self.children.values()] } class FlameGraphCLI: """Command-line interface for generating flame graphs.""" def __init__(self, args): self.args = args self.stack = Node("all", "root") self.session = None @staticmethod def get_libtype_from_dso(dso: Optional[str]) -> str: """Determine the library type from the DSO name.""" if dso and (dso == "[kernel.kallsyms]" or dso.endswith("/vmlinux") or dso == "[kernel]"): return "kernel" return "" @staticmethod def find_or_create_node(node: Node, name: str, libtype: str) -> Node: """Find a child node with the given name or create a new one.""" if name in node.children: return node.children[name] child = Node(name, libtype) node.children[name] = child return child def process_event(self, sample) -> None: """Process a single perf sample event.""" if self.args.event_name and self.args.event_name not in str(sample.evsel): return pid = sample.sample_pid dso_type = "" try: thread = self.session.find_thread(sample.sample_pid, sample.sample_tid) comm = (thread.comm() if thread else None) or "[unknown]" except (OSError, ValueError, KeyError, RuntimeError, TypeError, AttributeError): comm = "[unknown]" if pid == 0: comm = comm if comm != "[unknown]" else "swapper" dso_type = "kernel" else: comm = f"{comm} ({pid})" node = self.find_or_create_node(self.stack, comm, dso_type) callchain = sample.callchain if callchain: # We want to traverse from root to leaf. # perf callchain iterator gives leaf to root. # We collect them and reverse. frames = list(callchain) for entry in reversed(frames): name = entry.symbol or "[unknown]" libtype = self.get_libtype_from_dso(entry.dso) node = self.find_or_create_node(node, name, libtype) else: # Fallback if no callchain name = (sample.symbol or '[unknown]') libtype = self.get_libtype_from_dso((sample.dso or '[unknown]')) node = self.find_or_create_node(node, name, libtype) node.value += 1 def get_report_header(self) -> str: """Get the header from the perf report.""" try: input_file = self.args.input or "perf.data" if input_file == "-": return "" mode = os.stat(input_file).st_mode import stat if stat.S_ISFIFO(mode) or stat.S_ISCHR(mode): return "" output = subprocess.check_output(["perf", "report", "--header-only", "-i", input_file]) result = output.decode("utf-8") if self.args.event_name: result += "\nFocused event: " + self.args.event_name return result except (OSError, ValueError, KeyError, RuntimeError, TypeError, AttributeError, subprocess.CalledProcessError): return "" def run(self) -> None: """Run the flame graph generation.""" input_file = self.args.input or "perf.data" if input_file != "-" and not os.path.exists(input_file): print(f"Error: {input_file} not found. (try 'perf record' first)", file=sys.stderr) sys.exit(1) try: self.session = perf.session(perf.data(input_file), sample=self.process_event) except (OSError, ValueError, KeyError, RuntimeError, TypeError, AttributeError) as e: print(f"Error opening session: {e}", file=sys.stderr) sys.exit(1) try: self.session.process_events() finally: # perf.session is a C extension type that holds a strong reference to the # bound method self.process_event (which references self) and does not # implement cyclic GC (tp_traverse). Explicitly clear self.session to break # the reference cycle and free the underlying session resources deterministically. self.session = None stacks_json = json.dumps(self.stack, default=lambda x: x.to_json()) # Escape HTML special characters to prevent XSS stacks_json = stacks_json.replace("<", "\\u003c") \ .replace(">", "\\u003e").replace("&", "\\u0026") if self.args.format == "html": report_header = self.get_report_header() options = { "colorscheme": self.args.colorscheme, "context": report_header } options_json = json.dumps(options) options_json = options_json.replace("<", "\\u003c") \ .replace(">", "\\u003e").replace("&", "\\u0026") template = self.args.template template_sha256sum = None output_str = None if not os.path.isfile(template): if template.startswith("http://") or template.startswith("https://"): if not self.args.allow_download: print("Warning: Downloading templates is disabled. " "Use --allow-download.", file=sys.stderr) template = None else: print(f"Warning: Template file '{template}' not found.", file=sys.stderr) if self.args.allow_download: print("Using default CDN template.", file=sys.stderr) template = ( "https://cdn.jsdelivr.net/npm/d3-flame-graph@4.1.3/dist/templates/" "d3-flamegraph-base.html" ) template_sha256sum = ( "f6a4aa7edffda4fb9bd71eb0eb75bc44d0bb34cd9efbd053f6095bc5c28d702b" ) else: template = None use_minimal = False try: if not template: use_minimal = True elif template.startswith(("http://", "https://")): with urllib.request.urlopen(template) as url_template: output_str = "".join([l.decode("utf-8") for l in url_template.readlines()]) else: with open(template, "r", encoding="utf-8") as f: output_str = f.read() except (OSError, ValueError, KeyError, RuntimeError, TypeError, AttributeError) as err: print(f"Error reading template {template}: {err}\n", file=sys.stderr) use_minimal = True if use_minimal: print("Using internal minimal HTML that refers to d3's web site. JavaScript " + "loaded this way from a local file may be blocked unless your " + "browser has relaxed permissions. Run with '--allow-download' to fetch " + "the full D3 HTML template.", file=sys.stderr) output_str = MINIMAL_HTML elif template_sha256sum: assert output_str is not None download_sha256sum = hashlib.sha256( output_str.encode("utf-8") ).hexdigest() if download_sha256sum != template_sha256sum: s = None while s not in ["y", "n"]: try: s = input(f"""Unexpected template sha256sum. {download_sha256sum} != {template_sha256sum}, for: {template} continue?[yn] """).lower() except EOFError: s = "n" if s == "n": sys.exit(1) assert output_str is not None replacements = { "/** @options_json **/": options_json, "/** @flamegraph_json **/": stacks_json, } # Use a lambda replacement so re.sub does not interpret backslashes # in the JSON strings (such as \n or \u003c) as regex escape sequences. output_str = re.sub( r"/\*\* @(?:options_json|flamegraph_json) \*\*/", lambda m: replacements[m.group(0)], output_str, ) output_fn = self.args.output or "flamegraph.html" else: output_str = stacks_json output_fn = self.args.output or "stacks.json" if output_fn == "-": with open(sys.stdout.fileno(), "w", encoding="utf-8", closefd=False) as out: out.write(output_str) else: print(f"dumping data to {output_fn}") with open(output_fn, "w", encoding="utf-8") as out: out.write(output_str) if __name__ == "__main__": parser = argparse.ArgumentParser(description="Create flame graphs using perf python module.") parser.add_argument("-f", "--format", default="html", choices=["json", "html"], help="output file format") parser.add_argument("-o", "--output", help="output file name") parser.add_argument("--template", default="/usr/share/d3-flame-graph/d3-flamegraph-base.html", help="path to flame graph HTML template") parser.add_argument("--colorscheme", default="blue-green", help="flame graph color scheme", choices=["blue-green", "orange"]) parser.add_argument("-i", "--input", help="input perf.data file") parser.add_argument("--allow-download", default=False, action="store_true", help="allow unprompted downloading of HTML template") parser.add_argument("-e", "--event", default="", dest="event_name", type=str, help="specify the event to generate flamegraph for") cli_args = parser.parse_args() cli = FlameGraphCLI(cli_args) cli.run()