Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
name: CI

on:
push:
branches:
- main
pull_request:

permissions:
contents: read

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true

- name: Run tests
run: go test ./...
71 changes: 43 additions & 28 deletions DESIGN.md
Original file line number Diff line number Diff line change
@@ -1,27 +1,27 @@
# lazypprof — v0.1 Design
# lazypprof — Design

> Status: draft
> Scope: v0.1 only
> Status: current architecture notes

## Goal

A keyboard-driven TUI for exploring Go pprof profiles. Faster feedback loop than `go tool pprof -http`, usable over SSH, no browser.

## Non-goals (v0.1)
## Non-goals

- Diff between profiles
- Source-annotated view
- Profile types beyond CPU and heap (no goroutine, mutex, block, allocs)
- Symbol resolution against a binary (rely on what's in the profile)
- Persistent config / themes
- Tests as a deliverable (smoke tests only)
- Remote source fetching
- Persistent config / themes, for now

## In-scope (v0.1)
## In-scope

- Load CPU + heap profiles from local files
- Pull profiles from a live `/debug/pprof` endpoint
- Three views: **Top**, **Tree**, **Flame**
- Send live-mode headers and basic auth credentials for protected endpoints
- Diff between two local profiles
- Profile types: CPU, heap, allocs, goroutine, mutex, block
- Views: **Top**, **Tree**, **Flame**, **Goroutines**, **Source**
- Switch active sample type within a profile (e.g. `inuse_space` ↔ `alloc_space`)
- Keep live refresh history, show refresh failures, and preserve the previous profile on transient errors

---

Expand All @@ -33,11 +33,12 @@ main
├─ profile.Profile ← google/pprof/profile wrapper + aggregations
├─ views/top ← bubbles/table
├─ views/tree ← custom (collapsible)
├─ views/flame ← custom (canvas render)
├─ views/flame ← custom terminal render
├─ views/source ← file/line aggregate + optional local source text
└─ source/ ← local file | http poller
```

Single binary. No daemon. No persisted state in v0.1.
Single binary. No daemon. No persisted state.

### Package layout

Expand All @@ -57,9 +58,11 @@ internal/tui/flame/ # Flame view

## Data model

`profile.Profile` wraps `*pprof.Profile` plus the **active sample type** (a single string, e.g. `cpu`, `inuse_space`).
`profile.Profile` wraps `*pprof.Profile` plus the **active sample type** (a single string, e.g. `cpu`, `inuse_space`). Goroutine profiles also carry parsed text goroutine data and a synthetic pprof profile for shared views.

All view-specific aggregations are derived on demand from the active sample type. No cached intermediate state in v0.1 — re-aggregate on switch. Profiles are small enough (<10 MB typical) that this is fine.
The profile package owns small metadata helpers such as sample count, total value, duration, period, and period unit. The TUI uses these for compact header metadata.

All view-specific aggregations are derived on demand from the active sample type. Profiles are small enough (<10 MB typical) that this is fine for now.

### Aggregations

Expand All @@ -68,6 +71,7 @@ All view-specific aggregations are derived on demand from the active sample type
| Top | `[]FunctionStat{Name, File, Flat, Cum}` |
| Tree | rooted call graph: `Node{Func, Self, Cum, Children}` |
| Flame | same as Tree, rendered as nested rectangles |
| Source | `[]LineStat{File, Line, Function, Flat, Cum}` |

Tree and Flame share the same underlying call-graph build. Build once per (profile, sample-type) tuple; invalidate on switch.

Expand All @@ -83,6 +87,17 @@ Open path → `pprof.Parse` → done. Supports gzipped and raw.

Poll `GET <base>/debug/pprof/profile?seconds=N` (CPU) or `/debug/pprof/heap` (heap) on a configurable interval (default: 10s for CPU, 5s for heap). Replace the active `Profile` on successful fetch; keep showing the previous one on transient failure.

CPU capture duration follows the configured interval when `-interval` is set, clamped to 1s-30s. With automatic interval selection, CPU captures use 5s.

Protected services are supported with repeatable headers and basic auth:

```
lazypprof -H 'Authorization: Bearer token' https://service.internal:6060
lazypprof -user alice -password "$TOKEN" https://service.internal:6060
```

Header values and passwords are not rendered in the TUI.

CLI:

```
Expand All @@ -94,6 +109,10 @@ Heuristic: if arg starts with `http://` or `https://`, treat as live; else as fi

Rendering policy: don't blow away the user's selection/focus on refresh. Each view is responsible for preserving cursor across data updates (match by function name).

Refresh policy: pollers emit both success and failure events. The TUI displays update failures in the header and keeps rendering the last successful profile.

Metadata policy: the header shows compact profile metadata: sample count, total active value, duration when present, update time in live mode, and CPU capture duration in live CPU mode.

---

## Views
Expand All @@ -107,7 +126,7 @@ Rendering policy: don't blow away the user's selection/focus on refresh. Each vi
- Function names truncated to fit column with `…`
- Percentages computed against `sum(Flat)` for the active sample type

Open question: support sort-by-flat with a keybind? Probably yes, costs nothing.
Sort keys: `c` cumulative, `f` flat, `n` function name. Repeating the active sort key toggles direction.

### Tree

Expand Down Expand Up @@ -182,17 +201,13 @@ No others in v0.1. Resist the urge.
## Open questions

1. **Tree roots when there are many entrypoints** — top-K by Cum, or show a "roots" picker first? Lean: top-K with a key to expand the rest.
2. **Live mode auth** — do we need to support bearer tokens / basic auth on the `/debug/pprof` endpoint? Common in production. Probably defer to v0.2 unless trivial.
3. **Sample-type default for heap** — currently picks the last one (`inuse_space` for typical heap profiles). Verify this holds across Go versions.
4. **Flame graph color scheme** — warm palette by default. Add a `--cold` flag or theme later, not v0.1.
5. **Window too narrow** — flame graph degrades badly under ~80 cols. Show a "resize me" placeholder, or just render and let it be ugly?

---
2. **Sample-type default for heap** — currently picks the last one (`inuse_space` for typical heap profiles). Verify this holds across Go versions.
3. **Flame graph color scheme** — warm palette by default. Add a `--cold` flag or theme later.
4. **Window too narrow** — flame graph degrades badly under ~80 cols. Show a "resize me" placeholder, or just render and let it be ugly?

## Milestones
## Roadmap

- **M1 — Skeleton (done):** Bubble Tea wired, Top view working off file source
- **M2 — Tree view:** Call graph build + collapsible tree
- **M3 — Flame view:** Layout + render + focus/zoom
- **M4 — Live mode:** HTTP poller + state preservation
- **M5 — Polish:** Filter bar, help overlay, README + demo gif, release
- Export/non-interactive output for top, diff, flame, and metadata.
- Persistent preferences for default type, interval, initial view, and sort.
- Source view improvements, including path remapping and optional remote source lookup.
- Symbolization against a local binary when profile symbols are incomplete.
30 changes: 29 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,15 +35,21 @@ lazypprof -type goroutine http://localhost:6060 # goroutines (with state f
lazypprof -type mutex http://localhost:6060 # mutex contention
lazypprof -type block http://localhost:6060 # blocking operations

# Custom poll interval
# Custom poll interval (CPU capture duration follows this, clamped to 1s-30s)
lazypprof -interval 3s -type allocs http://localhost:6060

# Protected live services
lazypprof -H 'Authorization: Bearer token' https://service.internal:6060
lazypprof -user alice -password "$TOKEN" https://service.internal:6060

# Auto-detect: if nothing is specified, probes localhost:6060
lazypprof
```

Profile types: `cpu` (default), `heap`, `allocs`, `goroutine`, `mutex`, `block`

Live headers can be repeated with `-H` or `--header`. Basic auth is available with `-user` and `-password`.

## Demo

### File profile
Expand All @@ -56,6 +62,8 @@ Profile types: `cpu` (default), `heap`, `allocs`, `goroutine`, `mutex`, `block`

## Views

The header includes compact metadata such as sample count, total value, profile duration when present, live update time, and CPU capture duration.

**Top** — functions ranked by cumulative value. Standard pprof top view. In diff mode, rows are coloured red (regression) or green (improvement) with delta columns.

**Tree** — collapsible call tree. Expand/collapse nodes, navigate with keyboard. Filter matches are highlighted and paths auto-expanded.
Expand All @@ -64,6 +72,8 @@ Profile types: `cpu` (default), `heap`, `allocs`, `goroutine`, `mutex`, `block`

**Goroutines** — goroutines grouped by state (running, IO wait, select, etc). Drill into a state to see unique stacks with counts.

**Source** — hot source lines for a selected function. If the file exists locally, lazypprof shows source text; otherwise it still shows file/line aggregates from the profile.

## Keys

| Key | Action |
Expand All @@ -79,6 +89,16 @@ Profile types: `cpu` (default), `heap`, `allocs`, `goroutine`, `mutex`, `block`
| `?` | Help overlay |
| `q` | Quit |

### Top

| Key | Action |
| ----------- | ------------------------------ |
| `j/k` `↑/↓` | Navigate rows |
| `c` | Sort by cumulative value |
| `f` | Sort by flat value |
| `n` | Sort by function name |
| `enter/l` | Open Source view for function |

### Tree

| Key | Action |
Expand All @@ -89,6 +109,7 @@ Profile types: `cpu` (default), `heap`, `allocs`, `goroutine`, `mutex`, `block`
| `space` | Toggle |
| `*` | Expand subtree |
| `0` | Collapse all |
| `shift+l` | Open Source view |

### Flame

Expand All @@ -98,6 +119,7 @@ Profile types: `cpu` (default), `heap`, `allocs`, `goroutine`, `mutex`, `block`
| `enter` | Zoom in |
| `backspace` | Zoom out |
| `0` | Reset zoom |
| `shift+l` | Open Source view |

### Goroutines

Expand All @@ -108,6 +130,12 @@ Profile types: `cpu` (default), `heap`, `allocs`, `goroutine`, `mutex`, `block`
| `enter` | Drill into state |
| `backspace` | Back to groups |

### Source

| Key | Action |
| ----------- | ------------------ |
| `j/k` `↑/↓` | Navigate hot lines |

## Building

```bash
Expand Down
56 changes: 50 additions & 6 deletions cmd/lazypprof/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"flag"
"fmt"
"net"
"net/http"
"os"
"strings"
"time"
Expand All @@ -18,6 +19,11 @@ import (
func main() {
interval := flag.Duration("interval", 0, "poll interval for live mode (e.g. 5s, 10s); 0 = auto")
profileType := flag.String("type", "cpu", "profile type: cpu, heap, allocs, goroutine, mutex, block")
headers := headerFlags{}
flag.Var(&headers, "H", "HTTP header for live mode, in 'Name: value' form; repeatable")
flag.Var(&headers, "header", "HTTP header for live mode, in 'Name: value' form; repeatable")
username := flag.String("user", "", "basic auth username for live mode")
password := flag.String("password", "", "basic auth password for live mode")

flag.Usage = func() {
fmt.Fprintf(os.Stderr, "Usage: lazypprof [flags] <profile-file | base.prof target.prof | http://host:port>\n\n")
Expand All @@ -29,6 +35,8 @@ func main() {
fmt.Fprintf(os.Stderr, " lazypprof -type heap http://localhost:6060 # live heap\n")
fmt.Fprintf(os.Stderr, " lazypprof -type mutex http://localhost:6060 # live mutex\n")
fmt.Fprintf(os.Stderr, " lazypprof -interval 3s -type allocs http://host:6060 # live allocs, custom interval\n\n")
fmt.Fprintf(os.Stderr, " lazypprof -H 'Authorization: Bearer token' https://host:6060\n")
fmt.Fprintf(os.Stderr, " lazypprof -user alice -password \"$TOKEN\" https://host:6060\n\n")
fmt.Fprintf(os.Stderr, "Profile types: cpu (default), heap, allocs, goroutine, mutex, block\n")
fmt.Fprintf(os.Stderr, "Press [m] in live mode to switch between profile types.\n\n")
fmt.Fprintf(os.Stderr, "Flags:\n")
Expand All @@ -37,11 +45,17 @@ func main() {
flag.Parse()

pt := parseProfileType(*profileType)
auth := source.HTTPConfig{
Interval: *interval,
Headers: headers.Header(),
Username: *username,
Password: *password,
}

if flag.NArg() < 1 {
if url := probeLocalhost(); url != "" {
fmt.Fprintf(os.Stderr, "no target given; detected service at %s\n", url)
runLive(url, pt, *interval)
runLive(url, pt, auth)
return
}
flag.Usage()
Expand All @@ -58,14 +72,41 @@ func main() {
pt = legacy
}
}
runLive(arg, pt, *interval)
runLive(arg, pt, auth)
} else if flag.NArg() >= 2 && !source.Detect(flag.Arg(1)) {
runDiff(arg, flag.Arg(1))
} else {
runFile(arg)
}
}

type headerFlags []string

func (h *headerFlags) String() string {
return strings.Join(*h, ", ")
}

func (h *headerFlags) Set(value string) error {
name, _, ok := strings.Cut(value, ":")
if !ok {
return fmt.Errorf("header must be in 'Name: value' form")
}
if strings.TrimSpace(name) == "" {
return fmt.Errorf("header name cannot be empty")
}
*h = append(*h, value)
return nil
}

func (h headerFlags) Header() http.Header {
headers := make(http.Header)
for _, raw := range h {
name, value, _ := strings.Cut(raw, ":")
headers.Add(strings.TrimSpace(name), strings.TrimSpace(value))
}
return headers
}

// probeLocalhost checks whether a service is accepting TCP connections on
// localhost:6060. Returns the base URL to use, or empty string if nothing
// is listening.
Expand Down Expand Up @@ -142,8 +183,8 @@ func runFile(path string) {
}
}

func runLive(baseURL string, pt source.ProfileType, interval time.Duration) {
httpSrc := source.NewHTTPSource(baseURL, pt)
func runLive(baseURL string, pt source.ProfileType, auth source.HTTPConfig) {
httpSrc := source.NewHTTPSourceWithConfig(baseURL, pt, auth)
fmt.Fprintf(os.Stderr, "fetching from %s ...\n", httpSrc.URL)

prof, err := httpSrc.Load()
Expand All @@ -152,7 +193,7 @@ func runLive(baseURL string, pt source.ProfileType, interval time.Duration) {
os.Exit(1)
}

pollInterval := interval
pollInterval := auth.Interval
if pollInterval == 0 {
pollInterval = source.DefaultInterval(pt)
}
Expand All @@ -164,8 +205,11 @@ func runLive(baseURL string, pt source.ProfileType, interval time.Duration) {

cfg := &tui.LiveConfig{
BaseURL: baseURL,
Interval: interval, // 0 = auto per type
Interval: auth.Interval, // 0 = auto per type
ProfileType: pt,
Headers: auth.Headers,
Username: auth.Username,
Password: auth.Password,
}

model := tui.New(prof, poller.C, tui.WithLiveConfig(cfg, cancel))
Expand Down
Loading
Loading