// Copyright Epic Games, Inc. All Rights Reserved. // Interactive memory analysis view: summary cards, memory timeline, and a // tabbed callsite panel (Leaky / Churn / Hot) sharing one slot below the chart. import { getAllocSummary, getMemoryTimeline, getCallstackStats, getChurnStats, getCallstack, getAllocSizeHistogram } from "./api.js"; import { escapeHtml } from "./util.js"; function formatNum(n) { return Number(n || 0).toLocaleString(); } function formatBytes(bytes) { const sign = bytes < 0 ? "-" : ""; let value = Math.abs(Number(bytes || 0)); const units = ["B", "KB", "MB", "GB", "TB"]; let unit = 0; while (value >= 1024 && unit < units.length - 1) { value /= 1024; unit++; } const decimals = unit === 0 ? 0 : value >= 100 ? 0 : value >= 10 ? 1 : 2; return `${sign}${value.toFixed(decimals)} ${units[unit]}`; } function formatDistance(events) { return `${Math.round(Number(events || 0)).toLocaleString()} ev`; } function formatTimeAxis(us) { const value = Number(us || 0); if (value < 1000) return `${Math.round(value)} µs`; if (value < 1_000_000) return `${Math.round(value / 1000)} ms`; return `${Math.round(value / 1_000_000)} s`; } function chooseNiceTimeStep(spanUs, targetTickCount) { const rawStep = Math.max(1, spanUs / Math.max(1, targetTickCount)); const bases = [1, 2, 5]; const magnitude = Math.pow(10, Math.floor(Math.log10(rawStep))); for (const scale of [1, 10]) { for (const base of bases) { const step = base * magnitude * scale; if (step >= rawStep) { return step; } } } return 10 * magnitude; } function buildNiceTimeTicks(startUs, endUs, targetTickCount) { const spanUs = Math.max(1, endUs - startUs); const stepUs = chooseNiceTimeStep(spanUs, targetTickCount); const firstTickUs = Math.ceil(startUs / stepUs) * stepUs; const ticks = []; for (let tickUs = firstTickUs; tickUs <= endUs; tickUs += stepUs) { ticks.push(tickUs); } if (ticks.length === 0) { const roundedStart = Math.floor(startUs / stepUs) * stepUs; const roundedEnd = Math.ceil(endUs / stepUs) * stepUs; if (roundedStart >= startUs && roundedStart <= endUs) { ticks.push(roundedStart); } if (roundedEnd >= startUs && roundedEnd <= endUs && roundedEnd !== roundedStart) { ticks.push(roundedEnd); } } return ticks; } function trimPath(path) { if (!path) return ""; const parts = String(path).split(/[\\/]/); return parts[parts.length - 1] || path; } function compareValues(a, b, desc) { if (typeof a === "string" || typeof b === "string") { const result = String(a || "").localeCompare(String(b || ""), undefined, { numeric: true, sensitivity: "base" }); return desc ? -result : result; } const result = Number(a || 0) - Number(b || 0); return desc ? -result : result; } function escapeRegExp(s) { return String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } function highlightMatch(text, filterText) { const source = String(text || ""); if (!filterText) { return escapeHtml(source); } const regex = new RegExp(`(${escapeRegExp(filterText)})`, "ig"); return escapeHtml(source).replace(regex, '$1'); } function buildSummaryHtml(row, filterText = "") { const top = highlightMatch(row.top_frame || row.summary || `Callstack ${row.callstack_id}`, filterText); const second = row.secondary_frame ? `
${highlightMatch(row.secondary_frame, filterText)}
` : ""; const badges = []; if (row.hidden_prefix_count > 0) { badges.push(`skip ${escapeHtml(formatNum(row.hidden_prefix_count))}`); } if (row.included_third_party_boundary) { badges.push(`3p boundary`); } const badgeHtml = badges.length ? `
${badges.join("")}
` : ""; return `
${top}
${badgeHtml}
${second}
`; } function describeBucket(bucket) { const min = Number(bucket.min_size || 0); const max = Number(bucket.max_size || 0); if (min === 0 && max === 0) { return "0 bytes"; } if (min === max) { return `${formatBytes(min)}`; } return `${formatBytes(min)} – ${formatBytes(max)}`; } function formatBucketEdge(bucket) { const max = Number(bucket.max_size || 0); if (max === 0) { return "0"; } return formatBytes(max); } function sparklinePath(samples, width, height, valueIndex) { if (!samples || samples.length === 0) { return ""; } let minValue = Infinity; let maxValue = -Infinity; for (const sample of samples) { const value = Number(sample[valueIndex] || 0); minValue = Math.min(minValue, value); maxValue = Math.max(maxValue, value); } if (!isFinite(minValue) || !isFinite(maxValue)) { return ""; } if (minValue === maxValue) { minValue -= 1; maxValue += 1; } const count = samples.length; const points = []; for (let i = 0; i < count; ++i) { const x = count > 1 ? (i / (count - 1)) * width : width * 0.5; const norm = (Number(samples[i][valueIndex] || 0) - minValue) / (maxValue - minValue); const y = height - norm * height; points.push(`${i === 0 ? "M" : "L"}${x.toFixed(1)} ${y.toFixed(1)}`); } return points.join(" "); } export class MemoryView { constructor(model, containerEl) { this.model = model; this.container = containerEl; this.loaded = false; this.summary = null; this.memoryTimeline = null; this.leaks = []; this.churn = []; this.hot = []; this.sizeHistogram = null; this.histogramMetric = "count"; this.callstackCache = new Map(); this.selectedCallstackId = 0; this.tableState = { leaks: { sortKey: "live_bytes", desc: true, groupMode: "none", filterText: "" }, churn: { sortKey: "churn_allocs", desc: true, groupMode: "none", filterText: "" }, hot: { sortKey: "total_allocs", desc: true, groupMode: "none", filterText: "" }, }; this.activeTable = "leaks"; this.loadStateFromUrl(); this.buildLayout(); } static TAB_DEFS = [ { name: "leaks", label: "Leaky callsites" }, { name: "churn", label: "Churn" }, { name: "hot", label: "Hot callsites" }, ]; buildLayout() { this.container.innerHTML = `
` + `
` + `
` + `
` + `
Memory timeline
` + `
` + `
` + `
` + `` + `
` + `
` + `
` + `
` + `
` + `
Allocation size distribution
` + `
` + `
` + `
` + `` + `
` + `
` + `
` + `` + `
` + `
` + `
` + `
` + `
` + MemoryView.TAB_DEFS.map(({ name, label }) => `` ).join("") + `
` + this.buildPanelMarkup("leaks", "Leaky callsites", "Top live allocation stacks", [ ["live_bytes", "Live bytes"], ["live_count", "Live allocs"], ["summary", "Summary"], ]) + this.buildPanelMarkup("churn", "Churn", "Short-lived allocation sites", [ ["churn_allocs", "Short-lived allocs"], ["churn_bytes", "Churn bytes"], ["mean_distance", "Avg distance"], ["summary", "Summary"], ]) + this.buildPanelMarkup("hot", "Hot callsites", "Highest total allocation activity", [ ["total_allocs", "Total allocs"], ["total_bytes", "Total bytes"], ["churn_allocs", "Churn allocs"], ["summary", "Summary"], ]) + `
` + `
` + `
Callstack details
Select a row to inspect its frames
` + `
No callstack selected.
` + `
` + `
` + `
`; this.cardsEl = this.container.querySelector("#memory-cards"); this.chartWrapEl = this.container.querySelector("#memory-chart-wrap"); this.chartEl = this.container.querySelector("#memory-chart"); this.chartMetaEl = this.container.querySelector("#memory-timeline-meta"); this.histogramWrapEl = this.container.querySelector("#memory-histogram-wrap"); this.histogramEl = this.container.querySelector("#memory-histogram"); this.histogramMetaEl = this.container.querySelector("#memory-histogram-meta"); this.histogramMetricEl = this.container.querySelector("#memory-histogram-metric"); this.callstackMetaEl = this.container.querySelector("#memory-callstack-meta"); this.callstackBodyEl = this.container.querySelector("#memory-callstack-body"); this.panelRefs = { leaks: { tbody: this.container.querySelector("#memory-leaks-body"), sort: this.container.querySelector("#memory-leaks-sort"), filter: this.container.querySelector("#memory-leaks-filter"), clear: this.container.querySelector("#memory-leaks-clear"), direction: this.container.querySelector("#memory-leaks-direction"), group: this.container.querySelector("#memory-leaks-group"), }, churn: { tbody: this.container.querySelector("#memory-churn-body"), sort: this.container.querySelector("#memory-churn-sort"), filter: this.container.querySelector("#memory-churn-filter"), clear: this.container.querySelector("#memory-churn-clear"), direction: this.container.querySelector("#memory-churn-direction"), group: this.container.querySelector("#memory-churn-group"), }, hot: { tbody: this.container.querySelector("#memory-hot-body"), sort: this.container.querySelector("#memory-hot-sort"), filter: this.container.querySelector("#memory-hot-filter"), clear: this.container.querySelector("#memory-hot-clear"), direction: this.container.querySelector("#memory-hot-direction"), group: this.container.querySelector("#memory-hot-group"), }, }; this.resizeObserver = new ResizeObserver(() => { if (this.loaded) { this.renderTimeline(); this.renderSizeHistogram(); } }); this.resizeObserver.observe(this.chartWrapEl); this.resizeObserver.observe(this.histogramWrapEl); this.histogramMetricEl.value = this.histogramMetric; this.histogramMetricEl.addEventListener("change", () => { this.histogramMetric = this.histogramMetricEl.value; this.saveStateToUrl(); this.renderSizeHistogram(); }); for (const [name, refs] of Object.entries(this.panelRefs)) { refs.sort.value = this.tableState[name].sortKey; refs.group.value = this.tableState[name].groupMode; refs.filter.value = this.tableState[name].filterText; refs.sort.addEventListener("change", () => { this.tableState[name].sortKey = refs.sort.value; this.tableState[name].desc = refs.sort.value !== "mean_distance"; this.updateDirectionButton(name); this.saveStateToUrl(); this.renderTableByName(name); }); refs.direction.addEventListener("click", () => { this.tableState[name].desc = !this.tableState[name].desc; this.updateDirectionButton(name); this.saveStateToUrl(); this.renderTableByName(name); }); refs.filter.addEventListener("input", () => { this.tableState[name].filterText = refs.filter.value; this.updateFilterButton(name); this.saveStateToUrl(); this.renderTableByName(name); }); refs.clear.addEventListener("click", () => { refs.filter.value = ""; this.tableState[name].filterText = ""; this.updateFilterButton(name); this.saveStateToUrl(); this.renderTableByName(name); refs.filter.focus(); }); refs.group.addEventListener("change", () => { this.tableState[name].groupMode = refs.group.value; this.saveStateToUrl(); this.renderTableByName(name); }); this.updateDirectionButton(name); this.updateFilterButton(name); } this.tabButtons = {}; this.tabPanels = {}; for (const { name } of MemoryView.TAB_DEFS) { const button = this.container.querySelector(`[data-mem-tab="${name}"]`); const panel = this.container.querySelector(`[data-mem-tabpanel="${name}"]`); this.tabButtons[name] = button; this.tabPanels[name] = panel; button.addEventListener("click", () => this.setActiveTable(name)); } this.setActiveTable(this.activeTable, /*save=*/ false); this.container.addEventListener("keydown", (e) => { if (e.key !== "/" || e.defaultPrevented) { return; } const target = e.target; if (target && (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.tagName === "SELECT" || target.isContentEditable)) { return; } e.preventDefault(); const activeView = this.container.closest(".view"); if (activeView && activeView.hidden) { return; } const activeFilter = this.panelRefs[this.activeTable]?.filter; if (activeFilter) { activeFilter.focus(); activeFilter.select(); } }); this.container.tabIndex = -1; this.container.dataset.memoryView = "true"; } setActiveTable(name, save = true) { if (!this.tabButtons || !this.tabButtons[name]) { return; } this.activeTable = name; for (const { name: tabName } of MemoryView.TAB_DEFS) { const isActive = tabName === name; const button = this.tabButtons[tabName]; const panel = this.tabPanels[tabName]; button.classList.toggle("active", isActive); button.setAttribute("aria-selected", isActive ? "true" : "false"); button.tabIndex = isActive ? 0 : -1; panel.hidden = !isActive; } if (save) { this.saveStateToUrl(); } } buildPanelMarkup(name, title, subtitle, sortOptions) { const sortHtml = sortOptions.map(([value, label]) => ``).join(""); return ` `; } loadStateFromUrl() { const params = new URLSearchParams(window.location.search); const histogramMetric = params.get("mem_hist_metric"); if (histogramMetric === "count" || histogramMetric === "bytes") { this.histogramMetric = histogramMetric; } const activeTable = params.get("mem_table"); if (activeTable && this.tableState[activeTable]) { this.activeTable = activeTable; } for (const [name, state] of Object.entries(this.tableState)) { const sortKey = params.get(`mem_${name}_sort`); const groupMode = params.get(`mem_${name}_group`); const dir = params.get(`mem_${name}_dir`); const filterText = params.get(`mem_${name}_filter`); if (sortKey) { state.sortKey = sortKey; } if (groupMode) { state.groupMode = groupMode; } if (filterText) { state.filterText = filterText; } if (dir === "asc") { state.desc = false; } else if (dir === "desc") { state.desc = true; } } } saveStateToUrl() { const url = new URL(window.location.href); url.searchParams.set("mem_hist_metric", this.histogramMetric); url.searchParams.set("mem_table", this.activeTable); for (const [name, state] of Object.entries(this.tableState)) { url.searchParams.set(`mem_${name}_sort`, state.sortKey); url.searchParams.set(`mem_${name}_group`, state.groupMode); url.searchParams.set(`mem_${name}_dir`, state.desc ? "desc" : "asc"); if (state.filterText) { url.searchParams.set(`mem_${name}_filter`, state.filterText); } else { url.searchParams.delete(`mem_${name}_filter`); } } window.history.replaceState({}, "", url); } updateFilterButton(name) { const refs = this.panelRefs[name]; const hasText = !!this.tableState[name].filterText; refs.clear.disabled = !hasText; refs.clear.title = hasText ? "Clear filter" : "Filter is empty"; } updateDirectionButton(name) { const refs = this.panelRefs[name]; const desc = this.tableState[name].desc; refs.direction.textContent = desc ? "↓ Desc" : "↑ Asc"; refs.direction.setAttribute("aria-label", desc ? "Sort descending" : "Sort ascending"); refs.direction.title = desc ? "Sorting descending" : "Sorting ascending"; } async ensureLoaded() { if (this.loaded) return; this.loaded = true; await this.refresh(); } async refresh() { this.renderLoading(); try { const [summary, memoryTimeline, leakResponse, churnResponse, sizeHistogram] = await Promise.all([ getAllocSummary(), getMemoryTimeline({ maxSamples: 1200 }), getCallstackStats(100), getChurnStats(200), getAllocSizeHistogram(), ]); this.summary = summary; this.memoryTimeline = memoryTimeline; this.leaks = (leakResponse && leakResponse.stats) || []; this.churn = (churnResponse && churnResponse.stats) || []; this.sizeHistogram = sizeHistogram; this.hot = this.churn.slice().sort((a, b) => { if (b.total_allocs !== a.total_allocs) return b.total_allocs - a.total_allocs; return b.total_bytes - a.total_bytes; }).slice(0, 100); this.render(); } catch (e) { this.cardsEl.innerHTML = ""; this.chartEl.innerHTML = ""; this.chartMetaEl.textContent = ""; this.histogramEl.innerHTML = ""; this.histogramMetaEl.textContent = ""; for (const refs of Object.values(this.panelRefs)) { refs.tbody.innerHTML = `Failed to load memory data: ${escapeHtml(e.message)}`; } this.callstackBodyEl.innerHTML = `
Failed to load memory data.
`; } } renderLoading() { this.cardsEl.innerHTML = `
Loading
Memory analysis…
`; this.chartEl.innerHTML = ""; this.chartMetaEl.textContent = ""; this.histogramEl.innerHTML = ""; this.histogramMetaEl.textContent = "Loading…"; for (const refs of Object.values(this.panelRefs)) { refs.tbody.innerHTML = `Loading…`; } } render() { this.renderCards(); this.renderTimeline(); this.renderSizeHistogram(); this.renderTableByName("leaks"); this.renderTableByName("churn"); this.renderTableByName("hot"); } renderCards() { const s = this.summary || {}; this.cardsEl.innerHTML = [ this.formatCard("Peak memory", formatBytes(s.peak_bytes)), this.formatCard("End memory", formatBytes(s.end_bytes)), this.formatCard("Live allocations", formatNum(s.live_allocations)), this.formatCard("Total allocs", formatNum(s.total_allocs)), this.formatCard("Total frees", formatNum(s.total_frees)), this.formatCard("Reallocs", formatNum((s.total_realloc_allocs || 0) + (s.total_realloc_frees || 0))), ].join(""); } formatCard(label, value) { return `
${escapeHtml(label)}
${escapeHtml(value)}
`; } renderTimeline() { const samples = (this.memoryTimeline && this.memoryTimeline.samples) || []; if (samples.length === 0) { this.chartMetaEl.textContent = "No memory timeline samples"; this.chartEl.innerHTML = `No memory timeline samples`; return; } const width = Math.max(320, Math.floor(this.chartWrapEl.getBoundingClientRect().width) - 24); const height = 220; const padLeft = 56; const padRight = 12; const padTop = 12; const padBottom = 22; const chartWidth = width - padLeft - padRight; const chartHeight = height - padTop - padBottom; let minValue = Infinity; let maxValue = -Infinity; for (const sample of samples) { minValue = Math.min(minValue, Number(sample[1] || 0)); maxValue = Math.max(maxValue, Number(sample[1] || 0)); } if (minValue === maxValue) { minValue -= 1; maxValue += 1; } const xAt = (index) => samples.length > 1 ? (padLeft + (index / (samples.length - 1)) * chartWidth) : (padLeft + chartWidth * 0.5); const yAt = (value) => { const norm = (Number(value || 0) - minValue) / Math.max(1, maxValue - minValue); return padTop + chartHeight - norm * chartHeight; }; const path = samples.map((sample, index) => `${index === 0 ? "M" : "L"}${xAt(index).toFixed(1)} ${yAt(sample[1]).toFixed(1)}`).join(" "); const grid = []; for (let i = 0; i <= 4; ++i) { const y = padTop + chartHeight * i / 4; const value = maxValue + (minValue - maxValue) * i / 4; grid.push(``); grid.push(`${escapeHtml(formatBytes(value))}`); } const startUs = Number(samples[0][0] || 0); const endUs = Number(samples[samples.length - 1][0] || 0); const spanUs = Math.max(1, endUs - startUs); const targetTickCount = Math.max(3, Math.min(8, Math.floor(chartWidth / 110))); const ticks = buildNiceTimeTicks(startUs, endUs, targetTickCount); for (const timeUs of ticks) { const t = spanUs > 0 ? ((timeUs - startUs) / spanUs) : 0; const x = padLeft + chartWidth * t; grid.push(``); grid.push(`${escapeHtml(formatTimeAxis(timeUs))}`); } const durationUs = (this.model.session.trace_end_us || 0) - (this.model.session.trace_start_us || 0); this.chartMetaEl.textContent = `${formatNum(samples.length)} samples across ${(durationUs / 1_000_000).toFixed(2)} s`; this.chartEl.setAttribute("viewBox", `0 0 ${width} ${height}`); this.chartEl.setAttribute("preserveAspectRatio", "xMinYMin meet"); this.chartEl.innerHTML = `` + grid.join("") + ``; } renderSizeHistogram() { const buckets = (this.sizeHistogram && this.sizeHistogram.buckets) || []; if (buckets.length === 0) { this.histogramMetaEl.textContent = "No allocations recorded"; this.histogramEl.innerHTML = `No allocations recorded`; return; } const metric = this.histogramMetric === "bytes" ? "bytes" : "count"; const metricLabel = metric === "bytes" ? "Total bytes" : "Alloc count"; const valueFor = (b) => Number((metric === "bytes" ? b.bytes : b.count) || 0); const formatValue = metric === "bytes" ? formatBytes : formatNum; let maxValue = 0; let totalValue = 0; for (const bucket of buckets) { const v = valueFor(bucket); if (v > maxValue) maxValue = v; totalValue += v; } if (maxValue === 0) { maxValue = 1; } const width = Math.max(320, Math.floor(this.histogramWrapEl.getBoundingClientRect().width) - 24); const height = 240; const padLeft = 64; const padRight = 12; const padTop = 12; const padBottom = 42; const chartWidth = width - padLeft - padRight; const chartHeight = height - padTop - padBottom; const bucketCount = buckets.length; const slotWidth = chartWidth / bucketCount; const barGap = Math.max(1, Math.min(4, slotWidth * 0.15)); const barWidth = Math.max(1, slotWidth - barGap); const parts = []; parts.push(``); // Horizontal grid + y-axis labels at 0, 25, 50, 75, 100% of max. for (let i = 0; i <= 4; ++i) { const y = padTop + chartHeight * i / 4; const value = maxValue * (1 - i / 4); parts.push(``); parts.push(`${escapeHtml(formatValue(value))}`); } // Bars. X-axis labels are drawn for a subset of buckets to avoid overlap. const labelStride = Math.max(1, Math.ceil(bucketCount / Math.max(3, Math.floor(chartWidth / 64)))); for (let i = 0; i < bucketCount; ++i) { const bucket = buckets[i]; const value = valueFor(bucket); const barHeight = (value / maxValue) * chartHeight; const x = padLeft + i * slotWidth + barGap / 2; const y = padTop + chartHeight - barHeight; const label = describeBucket(bucket); const tooltip = `${label}\n${metricLabel}: ${formatValue(value)}\nAlloc count: ${formatNum(bucket.count)}\nTotal bytes: ${formatBytes(bucket.bytes)}`; parts.push( `${escapeHtml(tooltip)}` ); if (i % labelStride === 0 || i === bucketCount - 1) { const tickX = padLeft + i * slotWidth + slotWidth / 2; parts.push( `${escapeHtml(formatBucketEdge(bucket))}` ); } } // Axis title for x. parts.push( `Allocation size (power-of-two buckets)` ); const summaryTotalCount = Number((this.sizeHistogram && this.sizeHistogram.total_count) || 0); const summaryTotalBytes = Number((this.sizeHistogram && this.sizeHistogram.total_bytes) || 0); this.histogramMetaEl.textContent = `${formatNum(summaryTotalCount)} allocations, ${formatBytes(summaryTotalBytes)} total across ${bucketCount} bucket${bucketCount === 1 ? "" : "s"}`; this.histogramEl.setAttribute("viewBox", `0 0 ${width} ${height}`); this.histogramEl.setAttribute("preserveAspectRatio", "xMinYMin meet"); this.histogramEl.innerHTML = parts.join(""); } getRowsForTable(name) { if (name === "leaks") return this.leaks.slice(0, 100); if (name === "churn") return this.churn.slice(0, 100); return this.hot.slice(0, 100); } renderTableByName(name) { const refs = this.panelRefs[name]; const state = this.tableState[name]; let rows = this.getRowsForTable(name).slice(); const filterText = state.filterText.trim().toLowerCase(); if (filterText) { rows = rows.filter((row) => { const haystack = [ row.summary, row.top_frame, row.secondary_frame, row.group_key, `callstack ${row.callstack_id}`, ].join("\n").toLowerCase(); return haystack.includes(filterText); }); } rows.sort((a, b) => compareValues(a[state.sortKey], b[state.sortKey], state.desc)); if (!rows.length) { refs.tbody.innerHTML = `No data available.`; return; } const parts = []; let currentGroup = null; for (let index = 0; index < rows.length; ++index) { const row = rows[index]; const groupKey = state.groupMode === "top_frame" ? (row.top_frame || "(unknown)") : (state.groupMode === "prefix" ? (row.group_key || row.top_frame || "(unknown)") : null); if (groupKey !== null && groupKey !== currentGroup) { currentGroup = groupKey; parts.push(`${escapeHtml(groupKey)}`); } parts.push(this.renderDataRow(name, row, index)); } refs.tbody.innerHTML = parts.join(""); for (const tr of refs.tbody.querySelectorAll("tr[data-callstack-id]")) { tr.addEventListener("click", () => { const callstackId = Number(tr.dataset.callstackId); this.selectCallstack(callstackId); for (const rowEl of this.container.querySelectorAll("tr[data-callstack-id]")) { rowEl.classList.toggle("selected", Number(rowEl.dataset.callstackId) === callstackId); } }); } } renderDataRow(name, row, index) { if (name === "leaks") { return `` + `${index + 1}` + `${escapeHtml(formatBytes(row.live_bytes))}` + `${escapeHtml(formatNum(row.live_count))}` + `${buildSummaryHtml(row)}` + ``; } if (name === "churn") { return `` + `${index + 1}` + `${escapeHtml(formatNum(row.churn_allocs))}` + `${escapeHtml(formatBytes(row.churn_bytes))}` + `${escapeHtml(formatDistance(row.mean_distance))}` + `${buildSummaryHtml(row)}` + ``; } return `` + `${index + 1}` + `${escapeHtml(formatNum(row.total_allocs))}` + `${escapeHtml(formatBytes(row.total_bytes))}` + `${escapeHtml(formatNum(row.churn_allocs))}` + `${buildSummaryHtml(row)}` + ``; } async selectCallstack(callstackId) { this.selectedCallstackId = callstackId; this.callstackMetaEl.textContent = `Callstack ${callstackId}`; this.callstackBodyEl.innerHTML = `
Loading callstack ${callstackId}…
`; try { let callstack = this.callstackCache.get(callstackId); if (!callstack) { callstack = await getCallstack(callstackId); this.callstackCache.set(callstackId, callstack); } const frames = callstack.frames || []; if (!frames.length) { this.callstackBodyEl.innerHTML = `
No frames recorded for this callstack.
`; return; } const notes = []; if (callstack.hidden_prefix_count > 0) { let note = `Skipped ${formatNum(callstack.hidden_prefix_count)} leading frame(s)`; if (callstack.included_third_party_boundary) { note += "; kept boundary third-party callsite"; } notes.push(`
${escapeHtml(note)}.
`); } const items = []; for (let i = 0; i < frames.length; ++i) { const frame = frames[i]; const display = frame.display || frame.address || "(unknown frame)"; const extra = frame.module_path ? ` ${escapeHtml(trimPath(frame.module_path))}` : ""; items.push(`
  • #${frame.index ?? i} ${escapeHtml(display)}${extra}
  • `); } this.callstackBodyEl.innerHTML = `${notes.join("")}
      ${items.join("")}
    `; } catch (e) { this.callstackBodyEl.innerHTML = `
    Failed to load callstack ${callstackId}: ${escapeHtml(e.message)}
    `; } } }