]> git.ipfire.org Git - thirdparty/Python/cpython.git/commitdiff
gh-142927: Show module names instead of file paths in flamegraph (#146040)
authorivonastojanovic <80911834+ivonastojanovic@users.noreply.github.com>
Wed, 29 Apr 2026 09:00:07 +0000 (10:00 +0100)
committerGitHub <noreply@github.com>
Wed, 29 Apr 2026 09:00:07 +0000 (10:00 +0100)
Lib/profiling/sampling/_flamegraph_assets/flamegraph.css
Lib/profiling/sampling/_flamegraph_assets/flamegraph.js
Lib/profiling/sampling/_flamegraph_assets/flamegraph_template.html
Lib/profiling/sampling/heatmap_collector.py
Lib/profiling/sampling/module_utils.py [new file with mode: 0644]
Lib/profiling/sampling/stack_collector.py
Lib/test/test_profiling/test_sampling_profiler/test_collectors.py

index c4da169d15de889e071c84030299fb3945e44918..c93ee1e9dd470e5f34aa81d218f4a5b314e235f9 100644 (file)
@@ -315,6 +315,12 @@ body.resizing-sidebar {
 }
 
 /* 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 @@ body.resizing-sidebar {
    -------------------------------------------------------------------------- */
 
 #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);
index 3f884c4c690fd1f543ac81fcc13b9839b8bca879..1611bf754424c1361c40ef61a041c4bd8ecdf06c 100644 (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
index 9a77178aeff7ecfda417646b0e944d55b85be8d0..f1c5bb0300679af957db028c1213819a286207b3 100644 (file)
                   <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>
index ea1beec70d39f8a601d9f8ce8e40ccdb5981258f..5c36d78f5535e7157fa948028b3996809fadd2b5 100644 (file)
@@ -20,6 +20,7 @@ from ._format_utils import fmt
 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
 # ============================================================================
diff --git a/Lib/profiling/sampling/module_utils.py b/Lib/profiling/sampling/module_utils.py
new file mode 100644 (file)
index 0000000..dfde2b2
--- /dev/null
@@ -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
index 461ce95a25874ba535214085f3fa339cbdd62222..04622a8c1e89ef628118c5d8ae17443d158c7c6b 100644 (file)
@@ -12,6 +12,7 @@ from ._css_utils import get_combined_css
 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 @@ class FlamegraphCollector(StackTraceCollector):
         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 @@ class FlamegraphCollector(StackTraceCollector):
 
         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 @@ class FlamegraphCollector(StackTraceCollector):
                 "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 @@ class FlamegraphCollector(StackTraceCollector):
                 # 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 @@ class FlamegraphCollector(StackTraceCollector):
 
                 # Recurse
                 child_entry["children"] = convert_children(
-                    node["children"], min_samples
+                    node["children"], min_samples, path_info
                 )
                 out.append(child_entry)
 
@@ -239,8 +265,9 @@ class FlamegraphCollector(StackTraceCollector):
         # 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 @@ class FlamegraphCollector(StackTraceCollector):
         # 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 @@ class FlamegraphCollector(StackTraceCollector):
             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": {
index 503430ddf021630045f1a07144a0b2c165886958..240ec8a195c43b26955e918c0c484d272e0634ff 100644 (file)
@@ -436,6 +436,8 @@ class TestSampleProfilerComponents(unittest.TestCase):
         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)