Skip to content

Embedded Flame Graphs <> Interactive Embedded Flame Graphs for Traces #43

Open

Description

@iyashjayesh

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.

Activity

  1. iyashjayesh commented on Aug 29, 2026

    @iyashjayesh
    OwnerAuthor

    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 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:

    Duration: 202.75ms, Total samples = 30ms (14.80%)
          30ms   100%   100%       30ms   100%  main.highCPUUsage
    

    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:

    1. Never sampled — profiling runs on 1 call in SamplingRate.
    2. 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.
    3. 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.

  2. iyashjayesh commented on Aug 29, 2026

    @iyashjayesh
    OwnerAuthor

    Now unblocked — #73 landed

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions