Skip to content
Prev Previous commit
Next Next commit
Show module names instead of file paths in flamegraph
Display module names instead of full file paths
(/home/user/project/pkg/mod.py → pkg.mod) in flamegraph for
readability.
  • Loading branch information
ivonastojanovic committed Apr 7, 2026
commit 75e120b2a39c81c03b5573f6b1a4fd9cca52fb17
39 changes: 27 additions & 12 deletions Lib/profiling/sampling/_flamegraph_assets/flamegraph.js
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,9 @@ function resolveStringIndices(node, table) {
if (typeof resolved.funcname === 'number') {
resolved.funcname = resolveString(resolved.funcname, table);
}
if (typeof resolved.module_name === 'number') {
resolved.module_name = resolveString(resolved.module_name);
}

if (Array.isArray(resolved.source)) {
resolved.source = resolved.source.map(index =>
Expand All @@ -78,6 +81,11 @@ function resolveStringIndices(node, table) {
return resolved;
}

// Escape HTML special characters
function escapeHtml(str) {
return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}

function selectFlamegraphData() {
const baseData = isShowingElided ? elidedFlamegraphData : normalData;

Expand Down Expand Up @@ -228,6 +236,7 @@ function setupLogos() {
function updateStatusBar(nodeData, rootValue) {
const funcname = resolveString(nodeData.funcname) || resolveString(nodeData.name) || "--";
const filename = resolveString(nodeData.filename) || "";
const moduleName = resolveString(nodeData.module_name) || "";
const lineno = nodeData.lineno;
const timeMs = (nodeData.value / 1000).toFixed(2);
const percent = rootValue > 0 ? ((nodeData.value / rootValue) * 100).toFixed(1) : "0.0";
Expand All @@ -249,8 +258,7 @@ function updateStatusBar(nodeData, rootValue) {

const fileEl = document.getElementById('status-file');
if (fileEl && filename && filename !== "~") {
const basename = filename.split('/').pop();
fileEl.textContent = lineno ? `${basename}:${lineno}` : basename;
fileEl.textContent = lineno ? `${moduleName}:${lineno}` : moduleName;
}

const funcEl = document.getElementById('status-func');
Expand Down Expand Up @@ -301,6 +309,7 @@ function createPythonTooltip(data) {

const funcname = resolveString(d.data.funcname) || resolveString(d.data.name);
const filename = resolveString(d.data.filename) || "";
const moduleName = escapeHtml(resolveString(d.data.module_name) || "");
const isSpecialFrame = filename === "~";

// Build source section
Expand All @@ -309,7 +318,7 @@ function createPythonTooltip(data) {
const sourceLines = source
.map((line) => {
const isCurrent = line.startsWith("→");
const escaped = line.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
const escaped = escapeHtml(line);
return `<div class="tooltip-source-line${isCurrent ? ' current' : ''}">${escaped}</div>`;
})
.join("");
Expand Down Expand Up @@ -369,7 +378,7 @@ function createPythonTooltip(data) {
}

const fileLocationHTML = isSpecialFrame ? "" : `
<div class="tooltip-location">${filename}${d.data.lineno ? ":" + d.data.lineno : ""}</div>`;
<div class="tooltip-location">${moduleName}${d.data.lineno ? ":" + d.data.lineno : ""}</div>`;

// Differential stats section
let diffSection = "";
Expand Down Expand Up @@ -628,24 +637,24 @@ function updateSearchHighlight(searchTerm, searchInput) {
const name = resolveString(d.data.name) || "";
const funcname = resolveString(d.data.funcname) || "";
const filename = resolveString(d.data.filename) || "";
const moduleName = resolveString(d.data.module_name) || "";
const lineno = d.data.lineno;
const term = searchTerm.toLowerCase();

// Check if search term looks like file:line pattern
// Check if search term looks like module:line pattern
const fileLineMatch = term.match(/^(.+):(\d+)$/);
let matches = false;

if (fileLineMatch) {
// Exact file:line matching
const searchFile = fileLineMatch[1];
const searchLine = parseInt(fileLineMatch[2], 10);
const basename = filename.split('/').pop().toLowerCase();
matches = basename.includes(searchFile) && lineno === searchLine;
matches = moduleName.toLowerCase().includes(searchFile) && lineno === searchLine;
} else {
// Regular substring search
matches =
name.toLowerCase().includes(term) ||
funcname.toLowerCase().includes(term) ||
moduleName.toLowerCase().includes(term) ||
filename.toLowerCase().includes(term);
}

Expand Down Expand Up @@ -1047,6 +1056,7 @@ function populateStats(data) {

let filename = resolveString(node.filename);
let funcname = resolveString(node.funcname);
let moduleName = resolveString(node.module_name);

if (!filename || !funcname) {
const nameStr = resolveString(node.name);
Expand All @@ -1061,6 +1071,7 @@ function populateStats(data) {

filename = filename || 'unknown';
funcname = funcname || 'unknown';
moduleName = moduleName || 'unknown';

if (filename !== 'unknown' && funcname !== 'unknown' && node.value > 0) {
let childrenValue = 0;
Expand All @@ -1077,12 +1088,14 @@ function populateStats(data) {
existing.directPercent = (existing.directSamples / totalSamples) * 100;
if (directSamples > existing.maxSingleSamples) {
existing.filename = filename;
existing.module_name = moduleName;
existing.lineno = node.lineno || '?';
existing.maxSingleSamples = directSamples;
}
} else {
functionMap.set(funcKey, {
filename: filename,
module_name: moduleName,
lineno: node.lineno || '?',
funcname: funcname,
directSamples,
Expand Down Expand Up @@ -1117,6 +1130,7 @@ function populateStats(data) {
const h = hotSpots[i];
const filename = h.filename || 'unknown';
const lineno = h.lineno ?? '?';
const moduleName = h.module_name || 'unknown';
const isSpecialFrame = filename === '~' && (lineno === 0 || lineno === '?');

let funcDisplay = h.funcname || 'unknown';
Expand All @@ -1127,8 +1141,7 @@ function populateStats(data) {
if (isSpecialFrame) {
fileEl.textContent = '--';
} else {
const basename = filename !== 'unknown' ? filename.split('/').pop() : 'unknown';
fileEl.textContent = `${basename}:${lineno}`;
fileEl.textContent = `${moduleName}:${lineno}`;
}
}
if (percentEl) percentEl.textContent = `${h.directPercent.toFixed(1)}%`;
Expand All @@ -1144,8 +1157,9 @@ function populateStats(data) {
if (card) {
if (i < hotSpots.length && hotSpots[i]) {
const h = hotSpots[i];
const basename = h.filename !== 'unknown' ? h.filename.split('/').pop() : '';
const searchTerm = basename && h.lineno !== '?' ? `${basename}:${h.lineno}` : h.funcname;
const moduleName = h.module_name || 'unknown';
const hasValidLocation = moduleName !== 'unknown' && h.lineno !== '?';
const searchTerm = hasValidLocation ? `${moduleName}:${h.lineno}` : h.funcname;
card.dataset.searchterm = searchTerm;
card.onclick = () => searchForHotspot(searchTerm);
card.style.cursor = 'pointer';
Expand Down Expand Up @@ -1281,6 +1295,7 @@ function accumulateInvertedNode(parent, stackFrame, leaf, isDifferential) {
self: 0,
children: {},
filename: stackFrame.filename,
module_name: stackFrame.module_name,
lineno: stackFrame.lineno,
funcname: stackFrame.funcname,
source: stackFrame.source,
Expand Down
29 changes: 19 additions & 10 deletions Lib/profiling/sampling/stack_collector.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from .collector import Collector, extract_lineno
from .opcode_utils import get_opcode_mapping
from .string_table import StringTable
from .module_utils import extract_module_name, get_python_path_info


class StackTraceCollector(Collector):
Expand Down Expand Up @@ -72,6 +73,7 @@ def __init__(self, *args, **kwargs):
self._sample_count = 0 # Track actual number of samples (not thread traces)
self._func_intern = {}
self._string_table = StringTable()
self._module_cache = {}
self._all_threads = set()

# Thread status statistics (similar to LiveStatsCollector)
Expand Down Expand Up @@ -168,19 +170,21 @@ def export(self, filename):

@staticmethod
@functools.lru_cache(maxsize=None)
def _format_function_name(func):
def _format_function_name(func, module_name):
filename, lineno, funcname = func

# Special frames like <GC> and <native> should not show file:line
if filename == "~" and lineno == 0:
return funcname

if len(filename) > 50:
parts = filename.split("/")
if len(parts) > 2:
filename = f".../{'/'.join(parts[-2:])}"
return f"{funcname} ({module_name}:{lineno})"

return f"{funcname} ({filename}:{lineno})"
def _get_module_name(self, filename, path_info):
module_name = self._module_cache.get(filename)
if module_name is None:
module_name, _ = extract_module_name(filename, path_info)
self._module_cache[filename] = module_name
return module_name

def _convert_to_flamegraph_format(self):
if self._total_samples == 0:
Expand All @@ -192,7 +196,7 @@ def _convert_to_flamegraph_format(self):
"strings": self._string_table.get_strings()
}

def convert_children(children, min_samples):
def convert_children(children, min_samples, path_info):
out = []
for func, node in children.items():
samples = node["samples"]
Expand All @@ -202,14 +206,18 @@ def convert_children(children, min_samples):
# Intern all string components for maximum efficiency
filename_idx = self._string_table.intern(func[0])
funcname_idx = self._string_table.intern(func[2])
name_idx = self._string_table.intern(self._format_function_name(func))
module_name = self._get_module_name(func[0], path_info)

module_name_idx = self._string_table.intern(module_name)
name_idx = self._string_table.intern(self._format_function_name(func, module_name))

child_entry = {
"name": name_idx,
"value": samples,
"self": node.get("self", 0),
"children": [],
"filename": filename_idx,
"module_name": module_name_idx,
"lineno": func[1],
"funcname": funcname_idx,
"threads": sorted(list(node.get("threads", set()))),
Expand All @@ -228,7 +236,7 @@ def convert_children(children, min_samples):

# Recurse
child_entry["children"] = convert_children(
node["children"], min_samples
node["children"], min_samples, path_info
)
out.append(child_entry)

Expand All @@ -239,8 +247,9 @@ def convert_children(children, min_samples):
# Filter out very small functions (less than 0.1% of total samples)
total_samples = self._total_samples
min_samples = max(1, int(total_samples * 0.001))
path_info = get_python_path_info()

root_children = convert_children(self._root["children"], min_samples)
root_children = convert_children(self._root["children"], min_samples, path_info)
if not root_children:
return {
"name": self._string_table.intern("No significant data"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -435,12 +435,12 @@ def test_flamegraph_collector_basic(self):
strings = data.get("strings", [])
name = resolve_name(data, strings)
self.assertTrue(name.startswith("Program Root: "))
self.assertIn("func2 (file.py:20)", name)
self.assertIn("func2 (file:20)", name)
self.assertEqual(data["self"], 0) # non-leaf: no self time
children = data.get("children", [])
self.assertEqual(len(children), 1)
child = children[0]
self.assertIn("func1 (file.py:10)", resolve_name(child, strings))
self.assertIn("func1 (file:10)", resolve_name(child, strings))
self.assertEqual(child["value"], 1)
self.assertEqual(child["self"], 1) # leaf: all time is self

Expand Down