gh-142927: Show module names instead of file paths in flamegraph (#146040)

This commit is contained in:
ivonastojanovic 2026-04-29 10:00:07 +01:00 committed by GitHub
parent d71e3bc5a0
commit d4eee16659
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 215 additions and 141 deletions

View file

@ -315,6 +315,12 @@ .section-title {
}
/* View Mode Section */
.view-mode-section {
display: flex;
flex-direction: column;
gap: 8px;
}
.view-mode-section .section-content {
display: flex;
flex-direction: column;
@ -1067,7 +1073,8 @@ .d3-flame-graph g:first-of-type .d3-flame-graph-label {
-------------------------------------------------------------------------- */
#toggle-invert .toggle-track.on,
#toggle-elided .toggle-track.on {
#toggle-elided .toggle-track.on,
#toggle-path-display .toggle-track.on {
background: #8e44ad;
border-color: #8e44ad;
box-shadow: 0 0 8px rgba(142, 68, 173, 0.3);

View file

@ -6,6 +6,7 @@ let normalData = null;
let invertedData = null;
let currentThreadFilter = 'all';
let isInverted = false;
let useModuleNames = true;
// Heat colors are now defined in CSS variables (--heat-1 through --heat-8)
// and automatically switch with theme changes - no JS color arrays needed!
@ -64,6 +65,12 @@ function resolveStringIndices(node, table) {
if (typeof resolved.funcname === 'number') {
resolved.funcname = resolveString(resolved.funcname, table);
}
if (typeof resolved.module === 'number') {
resolved.module = resolveString(resolved.module, table);
}
if (typeof resolved.label === 'number') {
resolved.label = resolveString(resolved.label, table);
}
if (Array.isArray(resolved.source)) {
resolved.source = resolved.source.map(index =>
@ -78,6 +85,19 @@ function resolveStringIndices(node, table) {
return resolved;
}
// Escape HTML special characters
function escapeHtml(str) {
return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
// Get display path based on user preference (module or full path)
function getDisplayName(moduleName, filename) {
if (useModuleNames) {
return moduleName || filename;
}
return filename;
}
function selectFlamegraphData() {
const baseData = isShowingElided ? elidedFlamegraphData : normalData;
@ -228,6 +248,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) || "";
const lineno = nodeData.lineno;
const timeMs = (nodeData.value / 1000).toFixed(2);
const percent = rootValue > 0 ? ((nodeData.value / rootValue) * 100).toFixed(1) : "0.0";
@ -249,8 +270,8 @@ 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;
const displayName = getDisplayName(moduleName, filename);
fileEl.textContent = lineno ? `${displayName}:${lineno}` : displayName;
}
const funcEl = document.getElementById('status-func');
@ -301,6 +322,8 @@ function createPythonTooltip(data) {
const funcname = resolveString(d.data.funcname) || resolveString(d.data.name);
const filename = resolveString(d.data.filename) || "";
const moduleName = resolveString(d.data.module) || "";
const displayName = escapeHtml(useModuleNames ? (moduleName || filename) : filename);
const isSpecialFrame = filename === "~";
// Build source section
@ -309,7 +332,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("");
@ -369,7 +392,7 @@ function createPythonTooltip(data) {
}
const fileLocationHTML = isSpecialFrame ? "" : `
<div class="tooltip-location">${filename}${d.data.lineno ? ":" + d.data.lineno : ""}</div>`;
<div class="tooltip-location">${displayName}${d.data.lineno ? ":" + d.data.lineno : ""}</div>`;
// Differential stats section
let diffSection = "";
@ -586,6 +609,7 @@ function createFlamegraph(tooltip, rootValue, data) {
.minFrameSize(1)
.tooltip(tooltip)
.inverted(true)
.getName(d => resolveString(useModuleNames ? d.data.label : d.data.name) || resolveString(d.data.name) || '')
.setColorMapper(function (d) {
if (d.depth === 0) return 'transparent';
@ -628,25 +652,25 @@ 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) || "";
const displayName = getDisplayName(moduleName, filename);
const lineno = d.data.lineno;
const term = searchTerm.toLowerCase();
// Check if search term looks like file:line pattern
// Check if search term looks like path: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 = displayName.toLowerCase().includes(searchFile) && lineno === searchLine;
} else {
// Regular substring search
matches =
name.toLowerCase().includes(term) ||
funcname.toLowerCase().includes(term) ||
filename.toLowerCase().includes(term);
displayName.toLowerCase().includes(term);
}
if (matches) {
@ -1047,6 +1071,7 @@ function populateStats(data) {
let filename = resolveString(node.filename);
let funcname = resolveString(node.funcname);
let moduleName = resolveString(node.module);
if (!filename || !funcname) {
const nameStr = resolveString(node.name);
@ -1061,6 +1086,7 @@ function populateStats(data) {
filename = filename || 'unknown';
funcname = funcname || 'unknown';
moduleName = moduleName || 'unknown';
if (filename !== 'unknown' && funcname !== 'unknown' && node.value > 0) {
const directSamples = node.self || 0;
@ -1073,12 +1099,14 @@ function populateStats(data) {
existing.directPercent = (existing.directSamples / totalSamples) * 100;
if (directSamples > existing.maxSingleSamples) {
existing.filename = filename;
existing.module = moduleName;
existing.lineno = node.lineno || '?';
existing.maxSingleSamples = directSamples;
}
} else {
functionMap.set(funcKey, {
filename: filename,
module: moduleName,
lineno: node.lineno || '?',
funcname: funcname,
directSamples,
@ -1113,6 +1141,7 @@ function populateStats(data) {
const h = hotSpots[i];
const filename = h.filename || 'unknown';
const lineno = h.lineno ?? '?';
const moduleName = h.module || 'unknown';
const isSpecialFrame = filename === '~' && (lineno === 0 || lineno === '?');
let funcDisplay = h.funcname || 'unknown';
@ -1123,8 +1152,8 @@ function populateStats(data) {
if (isSpecialFrame) {
fileEl.textContent = '--';
} else {
const basename = filename !== 'unknown' ? filename.split('/').pop() : 'unknown';
fileEl.textContent = `${basename}:${lineno}`;
const displayName = getDisplayName(moduleName, filename);
fileEl.textContent = `${displayName}:${lineno}`;
}
}
if (percentEl) percentEl.textContent = `${h.directPercent.toFixed(1)}%`;
@ -1140,8 +1169,11 @@ 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 || 'unknown';
const filename = h.filename || 'unknown';
const displayName = getDisplayName(moduleName, filename);
const hasValidLocation = displayName !== 'unknown' && h.lineno !== '?';
const searchTerm = hasValidLocation ? `${displayName}:${h.lineno}` : h.funcname;
card.dataset.searchterm = searchTerm;
card.onclick = () => searchForHotspot(searchTerm);
card.style.cursor = 'pointer';
@ -1273,10 +1305,12 @@ function accumulateInvertedNode(parent, stackFrame, leaf, isDifferential) {
if (!parent.children[key]) {
const newNode = {
name: stackFrame.name,
label: stackFrame.label,
value: 0,
self: 0,
children: {},
filename: stackFrame.filename,
module: stackFrame.module,
lineno: stackFrame.lineno,
funcname: stackFrame.funcname,
source: stackFrame.source,
@ -1370,6 +1404,7 @@ function generateInvertedFlamegraph(data) {
const invertedRoot = {
name: data.name,
label: data.label,
value: data.value,
children: {},
stats: data.stats,
@ -1394,6 +1429,12 @@ function toggleInvert() {
updateFlamegraphView();
}
function togglePathDisplay() {
useModuleNames = !useModuleNames;
updateToggleUI('toggle-path-display', useModuleNames);
updateFlamegraphView();
}
// ============================================================================
// Initialization
// ============================================================================
@ -1441,6 +1482,11 @@ function initFlamegraph() {
if (toggleInvertBtn) {
toggleInvertBtn.addEventListener('click', toggleInvert);
}
const togglePathDisplayBtn = document.getElementById('toggle-path-display');
if (togglePathDisplayBtn) {
togglePathDisplayBtn.addEventListener('click', togglePathDisplay);
}
}
// Keyboard shortcut: Enter/Space activates toggle switches

View file

@ -117,6 +117,12 @@ <h3 class="section-title">View Mode</h3>
<span class="toggle-label" data-text="Elided" title="Code paths that existed in baseline but are missing from current profile">Elided</span>
</div>
<div class="toggle-switch" id="toggle-path-display" title="Toggle between module names and full file paths" tabindex="0">
<span class="toggle-label" data-text="File Paths">File Paths</span>
<div class="toggle-track on"></div>
<span class="toggle-label active" data-text="Module Names">Module Names</span>
</div>
<div class="toggle-switch" id="toggle-invert" title="Toggle between standard and inverted flamegraph view" tabindex="0">
<span class="toggle-label active" data-text="Flamegraph">Flamegraph</span>
<div class="toggle-track"></div>

View file

@ -20,6 +20,7 @@
from .collector import normalize_location, extract_lineno
from .opcode_utils import get_opcode_info, format_opcode
from .stack_collector import StackTraceCollector
from .module_utils import extract_module_name, get_python_path_info
# ============================================================================
@ -49,126 +50,6 @@ class TreeNode:
children: Dict[str, 'TreeNode'] = field(default_factory=dict)
# ============================================================================
# Module Path Analysis
# ============================================================================
def get_python_path_info():
"""Get information about Python installation paths for module extraction.
Returns:
dict: Dictionary containing stdlib path, site-packages paths, and sys.path entries.
"""
info = {
'stdlib': None,
'site_packages': [],
'sys_path': []
}
# Get standard library path from os module location
try:
if hasattr(os, '__file__') and os.__file__:
info['stdlib'] = Path(os.__file__).parent
except (AttributeError, OSError):
pass # Silently continue if we can't determine stdlib path
# Get site-packages directories
site_packages = []
try:
site_packages.extend(Path(p) for p in site.getsitepackages())
except (AttributeError, OSError):
pass # Continue without site packages if unavailable
# Get user site-packages
try:
user_site = site.getusersitepackages()
if user_site and Path(user_site).exists():
site_packages.append(Path(user_site))
except (AttributeError, OSError):
pass # Continue without user site packages
info['site_packages'] = site_packages
info['sys_path'] = [Path(p) for p in sys.path if p]
return info
def extract_module_name(filename, path_info):
"""Extract Python module name and type from file path.
Args:
filename: Path to the Python file
path_info: Dictionary from get_python_path_info()
Returns:
tuple: (module_name, module_type) where module_type is one of:
'stdlib', 'site-packages', 'project', or 'other'
"""
if not filename:
return ('unknown', 'other')
try:
file_path = Path(filename)
except (ValueError, OSError):
return (str(filename), 'other')
# Check if it's in stdlib
if path_info['stdlib'] and _is_subpath(file_path, path_info['stdlib']):
try:
rel_path = file_path.relative_to(path_info['stdlib'])
return (_path_to_module(rel_path), 'stdlib')
except ValueError:
pass
# Check site-packages
for site_pkg in path_info['site_packages']:
if _is_subpath(file_path, site_pkg):
try:
rel_path = file_path.relative_to(site_pkg)
return (_path_to_module(rel_path), 'site-packages')
except ValueError:
continue
# Check other sys.path entries (project files)
if not str(file_path).startswith(('<', '[')): # Skip special files
for path_entry in path_info['sys_path']:
if _is_subpath(file_path, path_entry):
try:
rel_path = file_path.relative_to(path_entry)
return (_path_to_module(rel_path), 'project')
except ValueError:
continue
# Fallback: just use the filename
return (_path_to_module(file_path), 'other')
def _is_subpath(file_path, parent_path):
try:
file_path.relative_to(parent_path)
return True
except (ValueError, OSError):
return False
def _path_to_module(path):
if isinstance(path, str):
path = Path(path)
# Remove .py extension
if path.suffix == '.py':
path = path.with_suffix('')
# Convert path separators to dots
parts = path.parts
# Handle __init__ files - they represent the package itself
if parts and parts[-1] == '__init__':
parts = parts[:-1]
return '.'.join(parts) if parts else path.stem
# ============================================================================
# Helper Classes
# ============================================================================

View file

@ -0,0 +1,102 @@
"""Utilities for extracting module names from file paths."""
import os
import site
import sys
from pathlib import Path
def get_python_path_info():
"""Get information about Python's search paths.
Returns:
dict: Dictionary containing stdlib path, site-packages paths, and sys.path entries.
"""
info = {
'stdlib': None,
'site_packages': [],
'sys_path': []
}
# Get standard library path from os module location
try:
if hasattr(os, '__file__') and os.__file__:
info['stdlib'] = Path(os.__file__).parent
except (AttributeError, OSError):
pass # Silently continue if we can't determine stdlib path
# Get site-packages directories
site_packages = []
try:
site_packages.extend(Path(p) for p in site.getsitepackages())
except (AttributeError, OSError):
pass # Continue without site packages if unavailable
# Get user site-packages
try:
user_site = site.getusersitepackages()
if user_site and Path(user_site).exists():
site_packages.append(Path(user_site))
except (AttributeError, OSError):
pass # Continue without user site packages
info['site_packages'] = site_packages
info['sys_path'] = [Path(p) for p in sys.path if p]
return info
def extract_module_name(filename, path_info):
"""Extract Python module name and type from file path.
Args:
filename: Path to the Python file
path_info: Dictionary from get_python_path_info()
Returns:
tuple: (module_name, module_type) where module_type is one of:
'stdlib', 'site-packages', 'project', or 'other'
"""
if not filename:
return ('unknown', 'other')
try:
file_path = Path(filename)
except (ValueError, OSError):
return (str(filename), 'other')
# Check if it's in stdlib
if path_info['stdlib'] and file_path.is_relative_to(path_info['stdlib']):
return (_path_to_module(file_path.relative_to(path_info['stdlib'])), 'stdlib')
# Check site-packages
for site_pkg in path_info['site_packages']:
if file_path.is_relative_to(site_pkg):
return (_path_to_module(file_path.relative_to(site_pkg)), 'site-packages')
# Check other sys.path entries (project files)
if not str(file_path).startswith(('<', '[')): # Skip special files
for path_entry in path_info['sys_path']:
if file_path.is_relative_to(path_entry):
return (_path_to_module(file_path.relative_to(path_entry)), 'project')
# Fallback: just use the filename
return (_path_to_module(file_path), 'other')
def _path_to_module(path):
if isinstance(path, str):
path = Path(path)
# Remove .py extension
if path.suffix == '.py':
path = path.with_suffix('')
# Convert path separators to dots, stripping root/drive (e.g. "/" or "C:\")
parts = [p for p in path.parts if p != path.root and p != path.drive]
# Handle __init__ files - they represent the package itself
if parts and parts[-1] == '__init__':
parts = parts[:-1]
return '.'.join(parts) if parts else path.stem

View file

@ -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):
@ -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)
@ -182,6 +184,24 @@ def _format_function_name(func):
return f"{funcname} ({filename}:{lineno})"
@staticmethod
@functools.lru_cache(maxsize=None)
def _format_module_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
return f"{funcname} ({module_name}:{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:
return {
@ -192,7 +212,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"]
@ -202,14 +222,20 @@ 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])
module_name = self._get_module_name(func[0], path_info)
module_idx = self._string_table.intern(module_name)
name_idx = self._string_table.intern(self._format_function_name(func))
label_idx = self._string_table.intern(self._format_module_name(func, module_name))
child_entry = {
"name": name_idx,
"label": label_idx,
"value": samples,
"self": node.get("self", 0),
"children": [],
"filename": filename_idx,
"module": module_idx,
"lineno": func[1],
"funcname": funcname_idx,
"threads": sorted(list(node.get("threads", set()))),
@ -228,7 +254,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)
@ -239,8 +265,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"),
@ -282,10 +309,11 @@ def convert_children(children, min_samples):
# If we only have one root child, make it the root to avoid redundant level
if len(root_children) == 1:
main_child = root_children[0]
# Update the name to indicate it's the program root
# Update name and label to indicate it's the program root
old_name = self._string_table.get_string(main_child["name"])
new_name = f"Program Root: {old_name}"
main_child["name"] = self._string_table.intern(new_name)
main_child["name"] = self._string_table.intern(f"Program Root: {old_name}")
old_label = self._string_table.get_string(main_child["label"])
main_child["label"] = self._string_table.intern(f"Program Root: {old_label}")
main_child["stats"] = {
**self.stats,
"thread_stats": thread_stats,
@ -296,8 +324,10 @@ def convert_children(children, min_samples):
main_child["opcode_mapping"] = opcode_mapping
return main_child
program_root_idx = self._string_table.intern("Program Root")
return {
"name": self._string_table.intern("Program Root"),
"name": program_root_idx,
"label": program_root_idx,
"value": total_samples,
"children": root_children,
"stats": {

View file

@ -436,6 +436,8 @@ def test_flamegraph_collector_basic(self):
name = resolve_name(data, strings)
self.assertTrue(name.startswith("Program Root: "))
self.assertIn("func2 (file.py:20)", name)
label = strings[data["label"]]
self.assertTrue(label.startswith("Program Root: "))
self.assertEqual(data["self"], 0) # non-leaf: no self time
children = data.get("children", [])
self.assertEqual(len(children), 1)