You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Description:
Currently, MoniGo allows users to download pprof reports for traced functions. To streamline the debugging process, I propose embedding an interactive Flame Graph viewer directly within the MoniGo dashboard.
Proposed Solution:
Instead of just offering a .pprof download, render an interactive visualization using a library like d3-flame-graph.
Allow users to click, zoom, and inspect call stacks directly in the browser.
Integrate this view into the existing "Function Details" page.
Benefits:
Instant Insights: Performance bottlenecks become visually obvious without requiring external tools like go tool pprof.
Better UX: Keeps the developer within the MoniGo UI for the entire debugging workflow.
I measured this rather than estimating it, and the headline finding reframes the issue.
The existing pprof path does not work in production
It is not "downloads" — it forks go tool pprof as a subprocess (core/function-metrics.go:326, :338), guarded by exec.LookPath("go").
In any distroless / scratch / alpine container — essentially every production deployment of a Go service — go is not on PATH. The "Open pprof text report" link opens a JSON body reading "Error: 'go' command not found. pprof reports require the Go SDK." The feature is already dead in production; it only works on a dev machine. -list additionally needs the source files on disk.
So this issue is bigger and better than it reads: it deletes the Go-toolchain dependency from the dashboard. The flame graph is the visible part.
Three more findings, all affecting what a flame graph would show
CPU flame graphs will be empty for most functions — measured, not predicted. I ran the checked-in profiles. For main.highCPUUsage, the example's deliberately CPU-burning function:
Three samples, one stack. A flame graph of that is a single column. Every other _cpu.prof in the repo is 211 bytes with Total samples = 0 — Go's CPU profiler samples at 100 Hz, so any call under ~10 ms usually captures nothing. The existing hasProfile guard does not catch this, because the profile was captured; it is just empty.
The heap profile is not the function's memory.pprof.WriteHeapProfile writes a process-wide inuse_space snapshot. The actual top of main.highMemoryUsage_mem.prof is runtime.allocm, tstorage.getMetric, bufio.NewReaderSize — monigo's own startup. Labelling it "memory flame graph for main.highMemoryUsage" is exactly the false-claim class recently removed from these models.
Concurrent traced calls corrupt each other's profiles. Fixed in #69 — the error from pprof.StartCPUProfile was discarded, so a second concurrent call recorded a path to a zero-byte file.
Profiles are never cleaned up. Not unbounded per call (filenames derive from the function name, so re-profiling overwrites), but the maxTrackedFunctions eviction deletes the map entry, not the file. Evicted functions leave orphans with nothing pointing at them. Visible now: core/monigo/profiles/ holds 28 files mixing Feb and Aug timestamps.
Recommendation: parse in Go, and hand-draw the graph
Parse server-side. Profiles written by runtime/pprof are fully symbolized — I decompressed one and the string table already contains main.highCPUUsage and full source paths. Parsing needs no binary, no source, no toolchain, which is what makes the toolchain deletion possible. github.com/google/pprof/profile has zero external dependencies — stdlib imports only.
Do not vendor d3. Measured from the actual npm tarballs:
bytes
d3-flame-graph dist
13,352
the d3 modules it needs
116,936
total
130,288
Current payload is 322,086 against a budget of 420,000 — headroom 97,914. Adding d3 puts it at 452,374, over budget, and consumes all remaining headroom. Shipping full d3.min.js nearly doubles the payload. Tree-shaking to ~410-425 KB leaves zero headroom and requires a JS build step this repo does not have — .github/workflows/test.yml runs only node --check, and its comment says so explicitly. Its skip-list still names echarts.min.js: the ghost of exactly this decision.
A flame graph is a partition layout — the one visualisation that is trivially hand-drawable:
child.x = running sum of prior siblings, scaled to parent width
child.w = value / rootValue × canvasWidth
child.y = depth × rowHeight
That is the whole algorithm. Less maths than drawSeries in overview.js already does. Estimate ~200-260 lines ≈ 7-9 KB — 7% of the d3 route, stays ~90 KB under budget, and inherits the design tokens instead of fighting d3-flamegraph.css.
Three empty states, not two
The current code gets this half right. Given the measurement above it needs three:
Never sampled — profiling runs on 1 call in SamplingRate.
Sampled, zero samples — the common case, and it does not exist in the code today. "Profile captured, but the call finished in 1.2 ms. Go's CPU profiler samples at 100 Hz, so nothing was recorded." This is the single most important thing to build — without it the feature reads as broken.
Sampled with data — draw.
Plus a mandatory caption on the memory tab, renamed to "Process heap (at call end)", not "Memory".
Stages
0 — fix the profile underneath. Partly done in feat(metrics): call counts and approximate latency percentiles (#68) #69. Remaining: a gate so concurrent calls skip rather than collide, and a decision on the ~200 ms StopCPUProfile stall every sampled call pays (which also blocks "just lower SamplingRate" as the answer to empty graphs).
1 — server-side parse to JSON. New endpoint. Note function-details is registered in five separate places (monigo.go:433, 491, 565, 624, 645) — all five need the new route, and that duplication deserves its own issue. Done when the endpoint returns valid JSON with t.Setenv("PATH", "") — that test is the proof the toolchain dependency is gone.
2 — hand-drawn SVG.Done when TestEmbeddedPayloadWithinBudget passes without touching maxEmbeddedBytes. That clause is the acceptance criterion for the whole no-dependency decision: if the budget has to move, the decision was wrong and should be reopened.
3 — cleanup. Delete orphaned .prof files on eviction. Follow-up: -top/-tree are also derivable in-process, which would let os/exec go entirely.
Interaction scope for v1: hover tooltip, click-to-zoom, reset, keyboard. Search, differential graphs, icicle toggle and animated transitions are explicitly out — each costs lines against the budget this plan exists to protect.
core/profile_report.go parses profiles in-process with github.com/google/pprof/profile, no toolchain required. collect() already walks every sample's frames, which is most of the work a flame graph tree needs.
Before planning the viewer, I measured what these profiles actually contain, because it changes what this feature should be.
The finding: CPU flame graphs would be blank 88% of the time
Every .prof on disk in this repo, parsed:
Profiles
Zero samples
CPU
24
21 (88%)
Memory
24
0 (0%)
Deepest stack seen: 17 frames.
Go's CPU profiler samples at 100Hz. A traced call shorter than ~10ms usually finishes between two samples and records nothing — and most traced functions are short. So the visualization this issue asks for, applied to CPU profiles, renders an empty rectangle in the overwhelmingly common case.
Memory profiles have data every single time.alloc_space is recorded per allocation, not sampled on a timer, so it does not have this problem.
That inverts the natural reading of this issue. The plan below leads with memory and treats CPU as the case that needs explaining rather than the headline.
Do not add d3
d3-flame-graph needs d3: ~278 KB + ~30 KB, against a 380 KB total embedded payload with a 420 KB budget enforced by TestEmbeddedAssetsStaySmall. It would nearly double the dashboard and blow the cap.
It also cannot come from a CDN — the dashboard must work airgapped, which TestNoExternalResources enforces — so it would have to be vendored into every consumer's binary. ECharts was removed for exactly this reason (1 MB for one chart, replaced with inline SVG).
A flame graph is a tree of nested rectangles. At 17 frames deep it needs no virtualization, no layout engine, and no library. Inline SVG, consistent with renderChart in overview.js.
Plan
1. Server: a flame tree endpoint. GET /monigo/api/v1/function-flame?name=&profile=cpu|mem returning a nested {name, value, children[]} tree.
Build it by merging samples root-first — sampleFunctions() in core/profile_report.go already returns frames leaf-first, so reverse and merge. Reuse valueIndex() so the value shown matches the text report rather than silently picking a different sample type. One line in apiRoutes(), and the reachability test from #74 covers it across all five routers automatically.
2. Client: inline SVG renderer.
Width proportional to value, one row per depth, colour by hash of function name so a given function keeps its colour between renders. Click to zoom into a subtree, click the breadcrumb to zoom out, hover for a tooltip with the full name, value and percentage. Keyboard: Escape resets. This is ~150 lines, no dependency.
3. Default to the profile that has data.
Open on memory. Offer CPU behind a toggle, and when the CPU profile is empty say why — the 100Hz sampling explanation renderProfileReport already produces — rather than drawing an empty frame. A blank flame graph reads as a broken feature.
4. Place it on the existing Functions detail panel, above the text report, which stays. The text report is still the thing you copy into an issue.
Worth deciding first
The 88% figure is a consequence of the ~200ms stall documented in #73: profiling cost is roughly constant, so the sampling rate cannot be raised to collect more CPU data without stalling more often. If CPU flame graphs are meant to be the point of this feature, that constraint has to be addressed first — and that is a change to how profiles are captured, not to how they are drawn.
If memory flame graphs are enough, this is straightforward and can start now.
Activity
iyashjayesh commentedon Aug 29, 2026
Implementation plan
I measured this rather than estimating it, and the headline finding reframes the issue.
The existing pprof path does not work in production
It is not "downloads" — it forks
go tool pprofas a subprocess (core/function-metrics.go:326,:338), guarded byexec.LookPath("go").In any distroless / scratch / alpine container — essentially every production deployment of a Go service —
gois not onPATH. The "Open pprof text report" link opens a JSON body reading"Error: 'go' command not found. pprof reports require the Go SDK."The feature is already dead in production; it only works on a dev machine.-listadditionally needs the source files on disk.So this issue is bigger and better than it reads: it deletes the Go-toolchain dependency from the dashboard. The flame graph is the visible part.
Three more findings, all affecting what a flame graph would show
CPU flame graphs will be empty for most functions — measured, not predicted. I ran the checked-in profiles. For
main.highCPUUsage, the example's deliberately CPU-burning function:Three samples, one stack. A flame graph of that is a single column. Every other
_cpu.profin the repo is 211 bytes withTotal samples = 0— Go's CPU profiler samples at 100 Hz, so any call under ~10 ms usually captures nothing. The existinghasProfileguard does not catch this, because the profile was captured; it is just empty.The heap profile is not the function's memory.
pprof.WriteHeapProfilewrites a process-wideinuse_spacesnapshot. The actual top ofmain.highMemoryUsage_mem.profisruntime.allocm,tstorage.getMetric,bufio.NewReaderSize— monigo's own startup. Labelling it "memory flame graph for main.highMemoryUsage" is exactly the false-claim class recently removed from these models.Concurrent traced calls corrupt each other's profiles. Fixed in #69 — the error from
pprof.StartCPUProfilewas discarded, so a second concurrent call recorded a path to a zero-byte file.Profiles are never cleaned up. Not unbounded per call (filenames derive from the function name, so re-profiling overwrites), but the
maxTrackedFunctionseviction deletes the map entry, not the file. Evicted functions leave orphans with nothing pointing at them. Visible now:core/monigo/profiles/holds 28 files mixing Feb and Aug timestamps.Recommendation: parse in Go, and hand-draw the graph
Parse server-side. Profiles written by
runtime/pprofare fully symbolized — I decompressed one and the string table already containsmain.highCPUUsageand full source paths. Parsing needs no binary, no source, no toolchain, which is what makes the toolchain deletion possible.github.com/google/pprof/profilehas zero external dependencies — stdlib imports only.Do not vendor d3. Measured from the actual npm tarballs:
Current payload is 322,086 against a budget of 420,000 — headroom 97,914. Adding d3 puts it at 452,374, over budget, and consumes all remaining headroom. Shipping full
d3.min.jsnearly doubles the payload. Tree-shaking to ~410-425 KB leaves zero headroom and requires a JS build step this repo does not have —.github/workflows/test.ymlruns onlynode --check, and its comment says so explicitly. Its skip-list still namesecharts.min.js: the ghost of exactly this decision.A flame graph is a partition layout — the one visualisation that is trivially hand-drawable:
That is the whole algorithm. Less maths than
drawSeriesinoverview.jsalready does. Estimate ~200-260 lines ≈ 7-9 KB — 7% of the d3 route, stays ~90 KB under budget, and inherits the design tokens instead of fightingd3-flamegraph.css.Three empty states, not two
The current code gets this half right. Given the measurement above it needs three:
SamplingRate.Plus a mandatory caption on the memory tab, renamed to "Process heap (at call end)", not "Memory".
Stages
StopCPUProfilestall every sampled call pays (which also blocks "just lower SamplingRate" as the answer to empty graphs).function-detailsis registered in five separate places (monigo.go:433, 491, 565, 624, 645) — all five need the new route, and that duplication deserves its own issue. Done when the endpoint returns valid JSON witht.Setenv("PATH", "")— that test is the proof the toolchain dependency is gone.TestEmbeddedPayloadWithinBudgetpasses without touchingmaxEmbeddedBytes. That clause is the acceptance criterion for the whole no-dependency decision: if the budget has to move, the decision was wrong and should be reopened..proffiles on eviction. Follow-up:-top/-treeare also derivable in-process, which would letos/execgo entirely.Interaction scope for v1: hover tooltip, click-to-zoom, reset, keyboard. Search, differential graphs, icicle toggle and animated transitions are explicitly out — each costs lines against the budget this plan exists to protect.
iyashjayesh commentedon Aug 29, 2026
Now unblocked — #73 landed
core/profile_report.goparses profiles in-process withgithub.com/google/pprof/profile, no toolchain required.collect()already walks every sample's frames, which is most of the work a flame graph tree needs.Before planning the viewer, I measured what these profiles actually contain, because it changes what this feature should be.
The finding: CPU flame graphs would be blank 88% of the time
Every
.profon disk in this repo, parsed:Deepest stack seen: 17 frames.
Go's CPU profiler samples at 100Hz. A traced call shorter than ~10ms usually finishes between two samples and records nothing — and most traced functions are short. So the visualization this issue asks for, applied to CPU profiles, renders an empty rectangle in the overwhelmingly common case.
Memory profiles have data every single time.
alloc_spaceis recorded per allocation, not sampled on a timer, so it does not have this problem.That inverts the natural reading of this issue. The plan below leads with memory and treats CPU as the case that needs explaining rather than the headline.
Do not add d3
d3-flame-graphneeds d3: ~278 KB + ~30 KB, against a 380 KB total embedded payload with a 420 KB budget enforced byTestEmbeddedAssetsStaySmall. It would nearly double the dashboard and blow the cap.It also cannot come from a CDN — the dashboard must work airgapped, which
TestNoExternalResourcesenforces — so it would have to be vendored into every consumer's binary. ECharts was removed for exactly this reason (1 MB for one chart, replaced with inline SVG).A flame graph is a tree of nested rectangles. At 17 frames deep it needs no virtualization, no layout engine, and no library. Inline SVG, consistent with
renderChartinoverview.js.Plan
1. Server: a flame tree endpoint.
GET /monigo/api/v1/function-flame?name=&profile=cpu|memreturning a nested{name, value, children[]}tree.Build it by merging samples root-first —
sampleFunctions()incore/profile_report.goalready returns frames leaf-first, so reverse and merge. ReusevalueIndex()so the value shown matches the text report rather than silently picking a different sample type. One line inapiRoutes(), and the reachability test from #74 covers it across all five routers automatically.2. Client: inline SVG renderer.
Width proportional to value, one row per depth, colour by hash of function name so a given function keeps its colour between renders. Click to zoom into a subtree, click the breadcrumb to zoom out, hover for a tooltip with the full name, value and percentage. Keyboard: Escape resets. This is ~150 lines, no dependency.
3. Default to the profile that has data.
Open on memory. Offer CPU behind a toggle, and when the CPU profile is empty say why — the 100Hz sampling explanation
renderProfileReportalready produces — rather than drawing an empty frame. A blank flame graph reads as a broken feature.4. Place it on the existing Functions detail panel, above the text report, which stays. The text report is still the thing you copy into an issue.
Worth deciding first
The 88% figure is a consequence of the ~200ms stall documented in #73: profiling cost is roughly constant, so the sampling rate cannot be raised to collect more CPU data without stalling more often. If CPU flame graphs are meant to be the point of this feature, that constraint has to be addressed first — and that is a change to how profiles are captured, not to how they are drawn.
If memory flame graphs are enough, this is straightforward and can start now.