diff --git a/.github/workflows/assets.yml b/.github/workflows/assets.yml index 7bf56578..c607a9ca 100644 --- a/.github/workflows/assets.yml +++ b/.github/workflows/assets.yml @@ -1,5 +1,5 @@ name: Assets -on: [push, pull_request] +on: [pull_request] jobs: check-assets: runs-on: ubuntu-latest diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 5e479944..ce2e28e8 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -1,4 +1,4 @@ -on: [push] +on: [pull_request] name: Coverage jobs: coverage: diff --git a/.github/workflows/tests-linux.yml b/.github/workflows/tests-linux.yml index f28075f2..1f9c446d 100644 --- a/.github/workflows/tests-linux.yml +++ b/.github/workflows/tests-linux.yml @@ -4,13 +4,11 @@ jobs: tests-linux: strategy: matrix: - go-version: [1.22.x, 1.23.x] + go-version: [1.24.x, 1.25.x, tip] runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 - with: - go-version-file: go.mod + - uses: actions/checkout@v5 + - uses: actions/setup-go@v6 - name: go mod tidy check run: go mod tidy -diff - name: Tests diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 654b94fc..9d649dfa 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -26,7 +26,7 @@ The user interface aims to be simple, light and minimal. To bootstrap the UI for development: - cd to `internal/static` - run `npm install` - - run `npm dev` and leave it running + - run `npm run dev` and leave it running - in another terminal, cd to an example, for example `_example/default` - run `go mod edit -replace=github.com/arl/statsviz=../../` to build the example with your local version of the Go code. If you haven't touched to the diff --git a/_example/dev/go.sum b/_example/dev/go.sum new file mode 100644 index 00000000..e4fa0085 --- /dev/null +++ b/_example/dev/go.sum @@ -0,0 +1,8 @@ +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= +golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= diff --git a/_example/work.go b/_example/work.go index cb55606f..37cec34f 100644 --- a/_example/work.go +++ b/_example/work.go @@ -17,10 +17,12 @@ func Work() { for { select { case <-clearTick.C: - m = make(map[int64]any) if rand.Intn(100) < 5 { runtime.GC() } + if rand.Intn(100) < 2 { + m = make(map[int64]any) + } case ts := <-tick.C: m[ts.UnixNano()] = newStruct() } diff --git a/clients.go b/clients.go new file mode 100644 index 00000000..f4555236 --- /dev/null +++ b/clients.go @@ -0,0 +1,99 @@ +package statsviz + +import ( + "context" + "sync" + + "github.com/gorilla/websocket" + + "github.com/arl/statsviz/internal/plot" +) + +type clients struct { + cfg *plot.Config + ctx context.Context + + mu sync.RWMutex + m map[*websocket.Conn]chan []byte +} + +func newClients(ctx context.Context, cfg *plot.Config) *clients { + return &clients{ + m: make(map[*websocket.Conn]chan []byte), + cfg: cfg, + ctx: ctx, + } +} + +type wsmsg struct { + Event string `json:"event"` + Data any `json:"data"` +} + +func (c *clients) add(conn *websocket.Conn) { + dbglog("adding client") + + // Send config first. + err := conn.WriteJSON(wsmsg{Event: "config", Data: c.cfg}) + if err != nil { + dbglog("failed to send config: %v", err) + return + } + + ch := make(chan []byte) + + go func() { + defer func() { + c.mu.Lock() + delete(c.m, conn) + c.mu.Unlock() + + dbglog("removed client") + }() + + for { + select { + case <-c.ctx.Done(): + return + case msg := <-ch: + if err := sendbuf(conn, msg); err != nil { + dbglog("failed to send data: %v", err) + return + } + } + } + }() + + c.mu.Lock() + defer c.mu.Unlock() + + c.m[conn] = ch +} + +func sendbuf(conn *websocket.Conn, buf []byte) error { + w, err := conn.NextWriter(websocket.TextMessage) + if err != nil { + return err + } + _, err1 := w.Write(buf) + err2 := w.Close() + if err1 != nil { + return err1 + } + return err2 +} + +func (c *clients) broadcast(buf []byte) { + c.mu.RLock() + defer c.mu.RUnlock() + + for _, ch := range c.m { + select { + case ch <- buf: + default: + // if a client is not keeping up, we + // drop the message for that client. + dbglog("dropping message to client") + } + } +} diff --git a/internal/plot/color.go b/internal/plot/color.go index 6366fc20..926e8462 100644 --- a/internal/plot/color.go +++ b/internal/plot/color.go @@ -20,45 +20,46 @@ func (c WeightedColor) MarshalJSON() ([]byte, error) { return []byte(str), nil } -// https://mdigi.tools/color-shades/ +// NOTE: shades obtained from https://mdigi.tools/color-shades/ + var BlueShades = []WeightedColor{ - {Value: 0.0, Color: color.RGBA{0xea, 0xf8, 0xfd, 1}}, - {Value: 0.1, Color: color.RGBA{0xbf, 0xeb, 0xfa, 1}}, - {Value: 0.2, Color: color.RGBA{0x94, 0xdd, 0xf6, 1}}, - {Value: 0.3, Color: color.RGBA{0x69, 0xd0, 0xf2, 1}}, - {Value: 0.4, Color: color.RGBA{0x3f, 0xc2, 0xef, 1}}, - {Value: 0.5, Color: color.RGBA{0x14, 0xb5, 0xeb, 1}}, - {Value: 0.6, Color: color.RGBA{0x10, 0x94, 0xc0, 1}}, - {Value: 0.7, Color: color.RGBA{0x0d, 0x73, 0x96, 1}}, - {Value: 0.8, Color: color.RGBA{0x09, 0x52, 0x6b, 1}}, - {Value: 0.9, Color: color.RGBA{0x05, 0x31, 0x40, 1}}, - {Value: 1.0, Color: color.RGBA{0x02, 0x10, 0x15, 1}}, + {Value: 0.0, Color: color.RGBA{0xea, 0xf8, 0xfd, 1}}, // rgb(234, 248, 253) + {Value: 0.1, Color: color.RGBA{0xbf, 0xeb, 0xfa, 1}}, // rgb(191, 235, 250) + {Value: 0.2, Color: color.RGBA{0x94, 0xdd, 0xf6, 1}}, // rgb(148, 221, 246) + {Value: 0.3, Color: color.RGBA{0x69, 0xd0, 0xf2, 1}}, // rgb(105, 208, 242) + {Value: 0.4, Color: color.RGBA{0x3f, 0xc2, 0xef, 1}}, // rgb(63, 194, 239) + {Value: 0.5, Color: color.RGBA{0x14, 0xb5, 0xeb, 1}}, // rgb(20, 181, 235) + {Value: 0.6, Color: color.RGBA{0x10, 0x94, 0xc0, 1}}, // rgb(16, 148, 192) + {Value: 0.7, Color: color.RGBA{0x0d, 0x73, 0x96, 1}}, // rgb(13, 115, 150) + {Value: 0.8, Color: color.RGBA{0x09, 0x52, 0x6b, 1}}, // rgb(9, 82, 107) + {Value: 0.9, Color: color.RGBA{0x05, 0x31, 0x40, 1}}, // rgb(5, 49, 64) + {Value: 1.0, Color: color.RGBA{0x02, 0x10, 0x15, 1}}, // rgb(2, 16, 21) } var PinkShades = []WeightedColor{ - {Value: 0.0, Color: color.RGBA{0xfe, 0xe7, 0xf3, 1}}, - {Value: 0.1, Color: color.RGBA{0xfc, 0xb6, 0xdc, 1}}, - {Value: 0.2, Color: color.RGBA{0xf9, 0x85, 0xc5, 1}}, - {Value: 0.3, Color: color.RGBA{0xf7, 0x55, 0xae, 1}}, - {Value: 0.4, Color: color.RGBA{0xf5, 0x24, 0x96, 1}}, - {Value: 0.5, Color: color.RGBA{0xdb, 0x0a, 0x7d, 1}}, - {Value: 0.6, Color: color.RGBA{0xaa, 0x08, 0x61, 1}}, - {Value: 0.7, Color: color.RGBA{0x7a, 0x06, 0x45, 1}}, - {Value: 0.8, Color: color.RGBA{0x49, 0x03, 0x2a, 1}}, - {Value: 0.9, Color: color.RGBA{0x18, 0x01, 0x0e, 1}}, - {Value: 1.0, Color: color.RGBA{0x00, 0x00, 0x00, 1}}, + {Value: 0.0, Color: color.RGBA{0xfe, 0xe7, 0xf3, 1}}, // rgb(254, 231, 243) + {Value: 0.1, Color: color.RGBA{0xfc, 0xb6, 0xdc, 1}}, // rgb(252, 182, 220) + {Value: 0.2, Color: color.RGBA{0xf9, 0x85, 0xc5, 1}}, // rgb(249, 133, 197) + {Value: 0.3, Color: color.RGBA{0xf7, 0x55, 0xae, 1}}, // rgb(247, 85, 174) + {Value: 0.4, Color: color.RGBA{0xf5, 0x24, 0x96, 1}}, // rgb(245, 36, 150) + {Value: 0.5, Color: color.RGBA{0xdb, 0x0a, 0x7d, 1}}, // rgb(219, 10, 125) + {Value: 0.6, Color: color.RGBA{0xaa, 0x08, 0x61, 1}}, // rgb(170, 8, 97) + {Value: 0.7, Color: color.RGBA{0x7a, 0x06, 0x45, 1}}, // rgb(122, 6, 69) + {Value: 0.8, Color: color.RGBA{0x49, 0x03, 0x2a, 1}}, // rgb(73, 3, 42) + {Value: 0.9, Color: color.RGBA{0x18, 0x01, 0x0e, 1}}, // rgb(24, 1, 14) + {Value: 1.0, Color: color.RGBA{0x00, 0x00, 0x00, 1}}, // rgb(0, 0, 0) } var GreenShades = []WeightedColor{ - {Value: 0.0, Color: color.RGBA{0xed, 0xf7, 0xf2, 0}}, - {Value: 0.1, Color: color.RGBA{0xc9, 0xe8, 0xd7, 0}}, - {Value: 0.2, Color: color.RGBA{0xa5, 0xd9, 0xbc, 0}}, - {Value: 0.3, Color: color.RGBA{0x81, 0xca, 0xa2, 0}}, - {Value: 0.4, Color: color.RGBA{0x5e, 0xbb, 0x87, 0}}, - {Value: 0.5, Color: color.RGBA{0x44, 0xa1, 0x6e, 0}}, - {Value: 0.6, Color: color.RGBA{0x35, 0x7e, 0x55, 0}}, - {Value: 0.7, Color: color.RGBA{0x26, 0x5a, 0x3d, 0}}, - {Value: 0.8, Color: color.RGBA{0x17, 0x36, 0x25, 0}}, - {Value: 0.9, Color: color.RGBA{0x08, 0x12, 0x0c, 0}}, - {Value: 1.0, Color: color.RGBA{0x00, 0x00, 0x00, 0}}, + {Value: 0.0, Color: color.RGBA{0xed, 0xf7, 0xf2, 0}}, // rgb(237, 247, 242) + {Value: 0.1, Color: color.RGBA{0xc9, 0xe8, 0xd7, 0}}, // rgb(201, 232, 215) + {Value: 0.2, Color: color.RGBA{0xa5, 0xd9, 0xbc, 0}}, // rgb(165, 217, 188) + {Value: 0.3, Color: color.RGBA{0x81, 0xca, 0xa2, 0}}, // rgb(129, 202, 162) + {Value: 0.4, Color: color.RGBA{0x5e, 0xbb, 0x87, 0}}, // rgb(94, 187, 135) + {Value: 0.5, Color: color.RGBA{0x44, 0xa1, 0x6e, 0}}, // rgb(68, 161, 110) + {Value: 0.6, Color: color.RGBA{0x35, 0x7e, 0x55, 0}}, // rgb(53, 126, 85) + {Value: 0.7, Color: color.RGBA{0x26, 0x5a, 0x3d, 0}}, // rgb(38, 90, 61) + {Value: 0.8, Color: color.RGBA{0x17, 0x36, 0x25, 0}}, // rgb(23, 54, 37) + {Value: 0.9, Color: color.RGBA{0x08, 0x12, 0x0c, 0}}, // rgb(8, 18, 12) + {Value: 1.0, Color: color.RGBA{0x00, 0x00, 0x00, 0}}, // rgb(0, 0, 0) } diff --git a/internal/plot/helpers.go b/internal/plot/helpers.go new file mode 100644 index 00000000..a1499b68 --- /dev/null +++ b/internal/plot/helpers.go @@ -0,0 +1,52 @@ +package plot + +import ( + "runtime/debug" + "time" +) + +// delta returns a function that computes the delta between successive calls. +func delta[T uint64 | float64]() func(T) T { + first := true + var last T + return func(cur T) T { + delta := cur - last + if first { + delta = 0 + first = false + } + last = cur + return delta + } +} + +// rate returns a function that computes the rate of change per second. +func rate[T uint64 | float64]() func(time.Time, T) float64 { + var last T + var lastTime time.Time + + return func(now time.Time, cur T) float64 { + if lastTime.IsZero() { + last = cur + lastTime = now + return 0 + } + + t := now.Sub(lastTime).Seconds() + rate := float64(cur-last) / t + + last = cur + lastTime = now + + return rate + } +} + +func goversion() string { + bnfo, ok := debug.ReadBuildInfo() + if ok { + return bnfo.GoVersion + } + + return "" +} diff --git a/internal/plot/hist.go b/internal/plot/hist.go index 3ab98847..240f327e 100644 --- a/internal/plot/hist.go +++ b/internal/plot/hist.go @@ -85,3 +85,11 @@ func downsampleCounts(h *metrics.Float64Histogram, factor int, slice []uint64) [ // Whatever sum remains, it goes to the last bucket. return append(slice, sum) } + +func floatseq(n int) []float64 { + seq := make([]float64, n) + for i := range n { + seq[i] = float64(i) + } + return seq +} diff --git a/internal/plot/indices_gen.go b/internal/plot/indices_gen.go new file mode 100644 index 00000000..8bd0022b --- /dev/null +++ b/internal/plot/indices_gen.go @@ -0,0 +1,66 @@ +//go:build ignore + +package main + +import ( + "bytes" + "fmt" + "go/format" + "runtime/metrics" + "slices" + "strings" +) + +func varname(metric string) string { + name, unit, ok := strings.Cut(metric, ":") + if !ok { + panic("didn't find ':' on " + metric) + } + + name = "idx" + name + "_" + unit + name = strings.ReplaceAll(name, "/", "_") + name = strings.ReplaceAll(name, "-", "_") + + return name +} + +type idxname struct { + idx string + name string +} + +// TODO: we could also get the number of buckets for histograms, and preprocess other stuff. + +func main() { + var all []idxname + for _, m := range metrics.All() { + if !strings.HasPrefix(m.Name, "/godebug/") { + all = append(all, idxname{varname(m.Name), m.Name}) + } + } + slices.SortFunc(all, func(a, b idxname) int { + return strings.Compare(a.idx, b.idx) + }) + + var out bytes.Buffer + + fmt.Fprintln(&out, `package plot +// Code generated by internal/plot/indices_gen.go; DO NOT EDIT. + +//lint:file-ignore ST1003 Ignore underscore in generated index names +//lint:file-ignore U1000 Ignore unused indices. they're generated +`) + fmt.Fprintln(&out, `var (`) + for _, m := range all { + fmt.Fprintf(&out, "%s = mustidx(%q)\n", m.idx, m.name) + } + fmt.Fprintln(&out, `)`) + + formatted, err := format.Source(out.Bytes()) + if err != nil { + fmt.Println("format error:", err) + fmt.Printf("%s", out.String()) + panic(err) + } + fmt.Println(string(formatted)) +} diff --git a/internal/plot/indices_go1.26.go b/internal/plot/indices_go1.26.go new file mode 100644 index 00000000..73b06b54 --- /dev/null +++ b/internal/plot/indices_go1.26.go @@ -0,0 +1,21 @@ +//go:build go1.26 + +package plot + +// Code generated by internal/plot/generate_indices.go; DO NOT EDIT. + +//lint:file-ignore ST1003 Ignore underscore in generated index names +//lint:file-ignore U1000 Ignore unused indices. they're generated + +var ( + idx_gc_cleanups_executed_cleanups = mustidx("/gc/cleanups/executed:cleanups") + idx_gc_cleanups_queued_cleanups = mustidx("/gc/cleanups/queued:cleanups") + idx_gc_finalizers_executed_finalizers = mustidx("/gc/finalizers/executed:finalizers") + idx_gc_finalizers_queued_finalizers = mustidx("/gc/finalizers/queued:finalizers") + idx_sched_goroutines_created_goroutines = mustidx("/sched/goroutines-created:goroutines") + idx_sched_goroutines_not_in_go_goroutines = mustidx("/sched/goroutines/not-in-go:goroutines") + idx_sched_goroutines_runnable_goroutines = mustidx("/sched/goroutines/runnable:goroutines") + idx_sched_goroutines_running_goroutines = mustidx("/sched/goroutines/running:goroutines") + idx_sched_goroutines_waiting_goroutines = mustidx("/sched/goroutines/waiting:goroutines") + idx_sched_threads_total_threads = mustidx("/sched/threads/total:threads") +) diff --git a/internal/plot/indices_pre_go1.26.go b/internal/plot/indices_pre_go1.26.go new file mode 100644 index 00000000..cd5c2e13 --- /dev/null +++ b/internal/plot/indices_pre_go1.26.go @@ -0,0 +1,59 @@ +package plot + +// Code generated by internal/plot/indices_gen.go; DO NOT EDIT. + +//lint:file-ignore ST1003 Ignore underscore in generated index names +//lint:file-ignore U1000 Ignore unused indices. they're generated + +var ( + idx_cgo_go_to_c_calls_calls = mustidx("/cgo/go-to-c-calls:calls") + idx_cpu_classes_gc_mark_assist_cpu_seconds = mustidx("/cpu/classes/gc/mark/assist:cpu-seconds") + idx_cpu_classes_gc_mark_dedicated_cpu_seconds = mustidx("/cpu/classes/gc/mark/dedicated:cpu-seconds") + idx_cpu_classes_gc_mark_idle_cpu_seconds = mustidx("/cpu/classes/gc/mark/idle:cpu-seconds") + idx_cpu_classes_gc_pause_cpu_seconds = mustidx("/cpu/classes/gc/pause:cpu-seconds") + idx_cpu_classes_gc_total_cpu_seconds = mustidx("/cpu/classes/gc/total:cpu-seconds") + idx_cpu_classes_idle_cpu_seconds = mustidx("/cpu/classes/idle:cpu-seconds") + idx_cpu_classes_scavenge_assist_cpu_seconds = mustidx("/cpu/classes/scavenge/assist:cpu-seconds") + idx_cpu_classes_scavenge_background_cpu_seconds = mustidx("/cpu/classes/scavenge/background:cpu-seconds") + idx_cpu_classes_scavenge_total_cpu_seconds = mustidx("/cpu/classes/scavenge/total:cpu-seconds") + idx_cpu_classes_total_cpu_seconds = mustidx("/cpu/classes/total:cpu-seconds") + idx_cpu_classes_user_cpu_seconds = mustidx("/cpu/classes/user:cpu-seconds") + idx_gc_cycles_automatic_gc_cycles = mustidx("/gc/cycles/automatic:gc-cycles") + idx_gc_cycles_forced_gc_cycles = mustidx("/gc/cycles/forced:gc-cycles") + idx_gc_cycles_total_gc_cycles = mustidx("/gc/cycles/total:gc-cycles") + idx_gc_gomemlimit_bytes = mustidx("/gc/gomemlimit:bytes") + idx_gc_heap_allocs_by_size_bytes = mustidx("/gc/heap/allocs-by-size:bytes") + idx_gc_heap_allocs_bytes = mustidx("/gc/heap/allocs:bytes") + idx_gc_heap_allocs_objects = mustidx("/gc/heap/allocs:objects") + idx_gc_heap_frees_by_size_bytes = mustidx("/gc/heap/frees-by-size:bytes") + idx_gc_heap_frees_bytes = mustidx("/gc/heap/frees:bytes") + idx_gc_heap_frees_objects = mustidx("/gc/heap/frees:objects") + idx_gc_heap_goal_bytes = mustidx("/gc/heap/goal:bytes") + idx_gc_heap_live_bytes = mustidx("/gc/heap/live:bytes") + idx_gc_heap_objects_objects = mustidx("/gc/heap/objects:objects") + idx_gc_scan_globals_bytes = mustidx("/gc/scan/globals:bytes") + idx_gc_scan_heap_bytes = mustidx("/gc/scan/heap:bytes") + idx_gc_scan_stack_bytes = mustidx("/gc/scan/stack:bytes") + idx_gc_stack_starting_size_bytes = mustidx("/gc/stack/starting-size:bytes") + idx_memory_classes_heap_free_bytes = mustidx("/memory/classes/heap/free:bytes") + idx_memory_classes_heap_objects_bytes = mustidx("/memory/classes/heap/objects:bytes") + idx_memory_classes_heap_released_bytes = mustidx("/memory/classes/heap/released:bytes") + idx_memory_classes_heap_stacks_bytes = mustidx("/memory/classes/heap/stacks:bytes") + idx_memory_classes_heap_unused_bytes = mustidx("/memory/classes/heap/unused:bytes") + idx_memory_classes_metadata_mcache_free_bytes = mustidx("/memory/classes/metadata/mcache/free:bytes") + idx_memory_classes_metadata_mcache_inuse_bytes = mustidx("/memory/classes/metadata/mcache/inuse:bytes") + idx_memory_classes_metadata_mspan_free_bytes = mustidx("/memory/classes/metadata/mspan/free:bytes") + idx_memory_classes_metadata_mspan_inuse_bytes = mustidx("/memory/classes/metadata/mspan/inuse:bytes") + idx_memory_classes_os_stacks_bytes = mustidx("/memory/classes/os-stacks:bytes") + idx_memory_classes_other_bytes = mustidx("/memory/classes/other:bytes") + idx_memory_classes_profiling_buckets_bytes = mustidx("/memory/classes/profiling/buckets:bytes") + idx_memory_classes_total_bytes = mustidx("/memory/classes/total:bytes") + idx_sched_gomaxprocs_threads = mustidx("/sched/gomaxprocs:threads") + idx_sched_goroutines_goroutines = mustidx("/sched/goroutines:goroutines") + idx_sched_latencies_seconds = mustidx("/sched/latencies:seconds") + idx_sched_pauses_stopping_gc_seconds = mustidx("/sched/pauses/stopping/gc:seconds") + idx_sched_pauses_stopping_other_seconds = mustidx("/sched/pauses/stopping/other:seconds") + idx_sched_pauses_total_gc_seconds = mustidx("/sched/pauses/total/gc:seconds") + idx_sched_pauses_total_other_seconds = mustidx("/sched/pauses/total/other:seconds") + idx_sync_mutex_wait_total_seconds = mustidx("/sync/mutex/wait/total:seconds") +) diff --git a/internal/plot/layouts.go b/internal/plot/layouts.go deleted file mode 100644 index 213c3529..00000000 --- a/internal/plot/layouts.go +++ /dev/null @@ -1,716 +0,0 @@ -package plot - -import ( - "runtime/metrics" -) - -func floatseq(n int) []float64 { - seq := make([]float64, n) - for i := range n { - seq[i] = float64(i) - } - return seq -} - -var garbageCollectionLayout = Scatter{ - Name: "garbage collection", - Title: "GC Memory Summary", - Type: "scatter", - Events: "lastgc", - Layout: ScatterLayout{ - Yaxis: ScatterYAxis{ - Title: "bytes", - TickSuffix: "B", - }, - }, - Subplots: []Subplot{ - {Name: "memory limit", Unitfmt: "%{y:.4s}B"}, - {Name: "in-use memory", Unitfmt: "%{y:.4s}B"}, - {Name: "heap live", Unitfmt: "%{y:.4s}B"}, - {Name: "heap goal", Unitfmt: "%{y:.4s}B"}, - }, - InfoText: ` -Memory limit is /gc/gomemlimit:bytes, the Go runtime memory limit configured by the user (via GOMEMLIMIT or debug.SetMemoryLimt), otherwise 0. -In-use memory is the total mapped memory minus released heap memory (/memory/classes/total - /memory/classes/heap/released). -Heap live is /gc/heap/live:bytes, heap memory occupied by live objects. -Heap goal is /gc/heap/goal:bytes, the heap size target at the end of each GC cycle.`, -} - -var heapDetailslLayout = Scatter{ - Name: "TODO(set later)", - Title: "Heap (details)", - Type: "scatter", - Events: "lastgc", - Layout: ScatterLayout{ - Yaxis: ScatterYAxis{ - Title: "bytes", - TickSuffix: "B", - }, - }, - Subplots: []Subplot{ - { - Name: "heap sys", - Unitfmt: "%{y:.4s}B", - }, - { - Name: "heap objects", - Unitfmt: "%{y:.4s}B", - }, - { - Name: "heap stacks", - Unitfmt: "%{y:.4s}B", - }, - { - Name: "heap goal", - Unitfmt: "%{y:.4s}B", - }, - }, - InfoText: ` -Heap sys is /memory/classes/heap/{objects + unused + released + free}. It's an estimate of all the heap memory obtained from the OS. -Heap objects is /memory/classes/heap/objects, the memory occupied by live objects and dead objects that have not yet been marked free by the GC. -Heap stacks is /memory/classes/heap/stacks, the memory used for stack space. -Heap goal is gc/heap/goal, the heap size target for the end of the GC cycle.`, -} - -var liveObjectsLayout = Scatter{ - Name: "TODO(set later)", - Title: "Live Objects in Heap", - Type: "bar", - Events: "lastgc", - Layout: ScatterLayout{ - Yaxis: ScatterYAxis{ - Title: "objects", - }, - }, - Subplots: []Subplot{ - { - Name: "live objects", - Unitfmt: "%{y:.4s}", - Color: RGBString(255, 195, 128), - }, - }, - InfoText: `Live objects is /gc/heap/objects. It's the number of objects, live or unswept, occupying heap memory.`, -} - -var liveBytesLayout = Scatter{ - Name: "TODO(set later)", - Title: "Live Bytes in Heap", - Type: "bar", - Events: "lastgc", - Layout: ScatterLayout{ - Yaxis: ScatterYAxis{ - Title: "bytes", - }, - }, - Subplots: []Subplot{ - { - Name: "live bytes", - Unitfmt: "%{y:.4s}B", - Color: RGBString(135, 182, 218), - }, - }, - InfoText: `Live bytes is /gc/heap/allocs - /gc/heap/frees. It's the number of bytes currently allocated (and not yet GC'ec) to the heap by the application.`, -} - -var mspanMCacheLayout = Scatter{ - Name: "TODO(set later)", - Title: "MSpan/MCache", - Type: "scatter", - Events: "lastgc", - Layout: ScatterLayout{ - Yaxis: ScatterYAxis{ - Title: "bytes", - TickSuffix: "B", - }, - }, - Subplots: []Subplot{ - { - Name: "mspan in-use", - Unitfmt: "%{y:.4s}B", - }, - { - Name: "mspan free", - Unitfmt: "%{y:.4s}B", - }, - { - Name: "mcache in-use", - Unitfmt: "%{y:.4s}B", - }, - { - Name: "mcache free", - Unitfmt: "%{y:.4s}B", - }, - }, - InfoText: ` -Mspan in-use is /memory/classes/metadata/mspan/inuse, the memory that is occupied by runtime mspan structures that are currently being used. -Mspan free is /memory/classes/metadata/mspan/free, the memory that is reserved for runtime mspan structures, but not in-use. -Mcache in-use is /memory/classes/metadata/mcache/inuse, the memory that is occupied by runtime mcache structures that are currently being used. -Mcache free is /memory/classes/metadata/mcache/free, the memory that is reserved for runtime mcache structures, but not in-use. -`, -} - -var goroutinesLayout = Scatter{ - Name: "TODO(set later)", - Title: "Goroutines", - Type: "scatter", - Layout: ScatterLayout{ - Yaxis: ScatterYAxis{ - Title: "goroutines", - }, - }, - Subplots: []Subplot{ - { - Name: "goroutines", - Unitfmt: "%{y}", - }, - }, - InfoText: "Goroutines is /sched/goroutines, the count of live goroutines.", -} - -func sizeClassesLayout(samples []metrics.Sample) Heatmap { - idxallocs := metricIdx["/gc/heap/allocs-by-size:bytes"] - idxfrees := metricIdx["/gc/heap/frees-by-size:bytes"] - - // Perform a sanity check on the number of buckets on the 'allocs' and - // 'frees' size classes histograms. Statsviz plots a single histogram based - // on those 2 so we want them to have the same number of buckets, which - // should be true. - allocsBySize := samples[idxallocs].Value.Float64Histogram() - freesBySize := samples[idxfrees].Value.Float64Histogram() - if len(allocsBySize.Buckets) != len(freesBySize.Buckets) { - panic("different number of buckets in allocs and frees size classes histograms") - } - - // No downsampling for the size classes histogram (factor=1) but we still - // need to adapt boundaries for plotly heatmaps. - buckets := downsampleBuckets(allocsBySize, 1) - - return Heatmap{ - Name: "TODO(set later)", - Title: "Size Classes", - Type: "heatmap", - UpdateFreq: 5, - Colorscale: BlueShades, - Buckets: floatseq(len(buckets)), - CustomData: buckets, - Hover: HeapmapHover{ - YName: "size class", - YUnit: "bytes", - ZName: "objects", - }, - InfoText: `This heatmap shows the distribution of size classes, using /gc/heap/allocs-by-size and /gc/heap/frees-by-size.`, - Layout: HeatmapLayout{ - YAxis: HeatmapYaxis{ - Title: "size class", - TickMode: "array", - TickVals: []float64{1, 9, 17, 25, 31, 37, 43, 50, 58, 66}, - TickText: []float64{1 << 4, 1 << 7, 1 << 8, 1 << 9, 1 << 10, 1 << 11, 1 << 12, 1 << 13, 1 << 14, 1 << 15}, - }, - }, - } -} - -func runnableTimeLayout(samples []metrics.Sample) Heatmap { - idxschedlat := metricIdx["/sched/latencies:seconds"] - - schedlat := samples[idxschedlat].Value.Float64Histogram() - histfactor := downsampleFactor(len(schedlat.Buckets), maxBuckets) - buckets := downsampleBuckets(schedlat, histfactor) - - return Heatmap{ - Name: "TODO(set later)", - Title: "Time Goroutines Spend in 'Runnable' state", - Type: "heatmap", - UpdateFreq: 5, - Colorscale: GreenShades, - Buckets: floatseq(len(buckets)), - CustomData: buckets, - Hover: HeapmapHover{ - YName: "duration", - YUnit: "duration", - ZName: "goroutines", - }, - Layout: HeatmapLayout{ - YAxis: HeatmapYaxis{ - Title: "duration", - TickMode: "array", - TickVals: []float64{6, 13, 20, 26, 33, 39.5, 46, 53, 60, 66, 73, 79, 86}, - TickText: []float64{1e-7, 1e-6, 1e-5, 1e-4, 1e-3, 5e-3, 1e-2, 5e-2, 1e-1, 5e-1, 1, 5, 10}, - }, - }, - InfoText: `This heatmap shows the distribution of the time goroutines have spent in the scheduler in a runnable state before actually running, uses /sched/latencies:seconds.`, - } -} - -var schedEventsLayout = Scatter{ - Name: "TODO(set later)", - Title: "Goroutine Scheduling Events", - Type: "scatter", - Events: "lastgc", - Layout: ScatterLayout{ - Yaxis: ScatterYAxis{ - Title: "events", - }, - }, - Subplots: []Subplot{ - { - Name: "events per unit of time", - Unitfmt: "%{y}", - }, - { - Name: "events per unit of time, per P", - Unitfmt: "%{y}", - }, - }, - InfoText: `Events per second is the sum of all buckets in /sched/latencies:seconds, that is, it tracks the total number of goroutine scheduling events. That number is multiplied by the constant 8. -Events per second per P (processor) is Events per second divided by current GOMAXPROCS, from /sched/gomaxprocs:threads. -NOTE: the multiplying factor comes from internal Go runtime source code and might change from version to version.`, -} - -var cgoLayout = Scatter{ - Name: "TODO(set later)", - Title: "CGO Calls", - Type: "bar", - Layout: ScatterLayout{ - Yaxis: ScatterYAxis{ - Title: "calls", - }, - }, - Subplots: []Subplot{ - { - Name: "calls from go to c", - Unitfmt: "%{y}", - Color: "red", - }, - }, - InfoText: "Shows the count of calls made from Go to C by the current process, per unit of time. Uses /cgo/go-to-c-calls:calls", -} - -var gcStackSizeLayout = Scatter{ - Name: "TODO(set later)", - Title: "Goroutine stack starting size", - Type: "scatter", - Layout: ScatterLayout{ - Yaxis: ScatterYAxis{ - Title: "bytes", - }, - }, - Subplots: []Subplot{ - { - Name: "new goroutines stack size", - Unitfmt: "%{y:.4s}B", - }, - }, - InfoText: "Shows the stack size of new goroutines, uses /gc/stack/starting-size:bytes", -} - -var gcCyclesLayout = Scatter{ - Name: "TODO(set later)", - Title: "Completed GC Cycles", - Type: "bar", - Layout: ScatterLayout{ - BarMode: "stack", - Yaxis: ScatterYAxis{ - Title: "cycles", - }, - }, - Subplots: []Subplot{ - { - Name: "automatic", - Unitfmt: "%{y}", - Type: "bar", - }, - { - Name: "forced", - Unitfmt: "%{y}", - Type: "bar", - }, - }, - InfoText: `Number of completed GC cycles, either forced of generated by the Go runtime.`, -} - -var memoryClassesLayout = Scatter{ - Name: "TODO(set later)", - Title: "Memory classes", - Type: "scatter", - Events: "lastgc", - Layout: ScatterLayout{ - Yaxis: ScatterYAxis{ - Title: "bytes", - TickSuffix: "B", - }, - }, - Subplots: []Subplot{ - { - Name: "os stacks", - Unitfmt: "%{y:.4s}B", - }, - { - Name: "other", - Unitfmt: "%{y:.4s}B", - }, - { - Name: "profiling buckets", - Unitfmt: "%{y:.4s}B", - }, - { - Name: "total", - Unitfmt: "%{y:.4s}B", - }, - }, - - InfoText: ` -OS stacks is /memory/classes/os-stacks, stack memory allocated by the underlying operating system. -Other is /memory/classes/other, memory used by execution trace buffers, structures for debugging the runtime, finalizer and profiler specials, and more. -Profiling buckets is /memory/classes/profiling/buckets, memory that is used by the stack trace hash map used for profiling. -Total is /memory/classes/total, all memory mapped by the Go runtime into the current process as read-write.`, -} - -var cpuGCLayout = Scatter{ - Name: "TODO(set later)", - Title: "CPU (Garbage Collector)", - Type: "scatter", - Events: "lastgc", - Layout: ScatterLayout{ - Yaxis: ScatterYAxis{ - Title: "cpu-seconds per seconds", - TickSuffix: "s", - }, - }, - Subplots: []Subplot{ - { - Name: "mark assist", - Unitfmt: "%{y:.4s}s", - }, - { - Name: "mark dedicated", - Unitfmt: "%{y:.4s}s", - }, - { - Name: "mark idle", - Unitfmt: "%{y:.4s}s", - }, - { - Name: "pause", - Unitfmt: "%{y:.4s}s", - }, - }, - - InfoText: `Cumulative metrics are converted to rates by Statsviz so as to be more easily comparable and readable. -All this metrics are overestimates, and not directly comparable to system CPU time measurements. Compare only with other /cpu/classes metrics. - -mark assist is /cpu/classes/gc/mark/assist, estimated total CPU time goroutines spent performing GC tasks to assist the GC and prevent it from falling behind the application. -mark dedicated is /cpu/classes/gc/mark/dedicated, Estimated total CPU time spent performing GC tasks on processors (as defined by GOMAXPROCS) dedicated to those tasks. -mark idle is /cpu/classes/gc/mark/idle, estimated total CPU time spent performing GC tasks on spare CPU resources that the Go scheduler could not otherwise find a use for. -pause is /cpu/classes/gc/pause, estimated total CPU time spent with the application paused by the GC. - -All metrics are rates in CPU-seconds per second.`, -} - -var cpuScavengerLayout = Scatter{ - Name: "TODO(set later)", - Title: "CPU (Scavenger)", - Type: "bar", - Events: "lastgc", - Layout: ScatterLayout{ - BarMode: "stack", - Yaxis: ScatterYAxis{ - Title: "cpu-seconds / second", - TickSuffix: "s", - }, - }, - Subplots: []Subplot{ - { - Name: "assist", - Unitfmt: "%{y:.4s}s", - Type: "bar", - }, - { - Name: "background", - Unitfmt: "%{y:.4s}s", - Type: "bar", - }, - }, - InfoText: `Breakdown of how the GC scavenger returns memory to the OS (eagerly vs background). -assist is the rate of /cpu/classes/scavenge/assist, the CPU time spent returning unused memory eagerly in response to memory pressure. -background is the rate of /cpu/classes/scavenge/background, the CPU time spent performing background tasks to return unused memory to the OS. - -Both metrics are rates in CPU-seconds per second.`, -} - -var cpuOverallLayout = Scatter{ - Name: "TODO(set later)", - Title: "CPU (Overall)", - Type: "bar", - Events: "lastgc", - Layout: ScatterLayout{ - BarMode: "stack", - Yaxis: ScatterYAxis{ - Title: "cpu-seconds / second", - TickSuffix: "s", - }, - }, - Subplots: []Subplot{ - { - Name: "user", - Unitfmt: "%{y:.4s}s", - Type: "bar", - }, - { - Name: "scavenge", - Unitfmt: "%{y:.4s}s", - Type: "bar", - }, - { - Name: "idle", - Unitfmt: "%{y:.4s}s", - Type: "bar", - }, - { - Name: "gc total", - Unitfmt: "%{y:.4s}s", - Type: "bar", - }, - { - Name: "total", - Unitfmt: "%{y:.4s}s", - Type: "scatter", - }, - }, - InfoText: `Shows the fraction of CPU spent in your code vs. runtime vs. wasted. Helps track overall utilization and potential headroom. -user is the rate of /cpu/classes/user:cpu-seconds, the CPU time spent running user Go code. -scavenge is the rate of /cpu/classes/scavenge:cpu-seconds, the CPU time spent performing tasks that return unused memory to the OS. -idle is the rate of /cpu/classes/idle:cpu-seconds, the CPU time spent performing GC tasks on spare CPU resources that the Go scheduler could not otherwise find a use for. -gc total is the rate of /cpu/classes/gc/total:cpu-seconds, the CPU time spent performing GC tasks (sum of all metrics in /cpu/classes/gc) -total is the rate of /cpu/classes/total:cpu-seconds, the available CPU time for user Go code or the Go runtime, as defined by GOMAXPROCS. In other words, GOMAXPROCS integrated over the wall-clock duration this process has been executing for. - -All metrics are rates in CPU-seconds per second.`, -} - -var mutexWaitLayout = Scatter{ - Name: "TODO(set later)", - Title: "Mutex wait time", - Type: "bar", - Events: "lastgc", - Layout: ScatterLayout{ - Yaxis: ScatterYAxis{ - Title: "seconds / second", - TickSuffix: "s", - }, - }, - Subplots: []Subplot{ - { - Name: "mutex wait", - Unitfmt: "%{y:.4s}s", - Type: "bar", - }, - }, - - InfoText: `Cumulative metrics are converted to rates by Statsviz so as to be more easily comparable and readable. -mutex wait is /sync/mutex/wait/total, approximate cumulative time goroutines have spent blocked on a sync.Mutex or sync.RWMutex. - -This metric is useful for identifying global changes in lock contention. Collect a mutex or block profile using the runtime/pprof package for more detailed contention data.`, -} - -var gcScanLayout = Scatter{ - Name: "TODO(set later)", - Title: "GC Scan", - Type: "bar", - Events: "lastgc", - Layout: ScatterLayout{ - BarMode: "stack", - Yaxis: ScatterYAxis{ - TickSuffix: "B", - Title: "bytes", - }, - }, - Subplots: []Subplot{ - { - Name: "scannable globals", - Unitfmt: "%{y:.4s}B", - Type: "bar", - }, - { - Name: "scannable heap", - Unitfmt: "%{y:.4s}B", - Type: "bar", - }, - { - Name: "scanned stack", - Unitfmt: "%{y:.4s}B", - Type: "bar", - }, - }, - InfoText: ` -This plot shows the amount of memory that is scannable by the GC. -scannable globals is /gc/scan/globals, the total amount of global variable space that is scannable. -scannable heap is /gc/scan/heap, the total amount of heap space that is scannable. -scanned stack is /gc/scan/stack, the number of bytes of stack that were scanned last GC cycle. -`, -} - -var allocFreeRatesLayout = Scatter{ - Name: "heap alloc/free rates", - Title: "Heap Allocation & Free Rates", - Type: "scatter", - Layout: ScatterLayout{ - Yaxis: ScatterYAxis{ - Title: "objects / second", - }, - }, - Subplots: []Subplot{ - { - Name: "allocs/sec", - Unitfmt: "%{y:.4s}", - Color: RGBString(66, 133, 244), - }, - { - Name: "frees/sec", - Unitfmt: "%{y:.4s}", - Color: RGBString(219, 68, 55), - }, - }, - InfoText: ` -Allocations per second is derived by differencing the cumulative /gc/heap/allocs:objects metric. -Frees per second is similarly derived from /gc/heap/frees:objects.`, -} - -func gcTotalPausesLayout(samples []metrics.Sample) Heatmap { - idxgcpauses := metricIdx["/sched/pauses/total/gc:seconds"] - - gcpauses := samples[idxgcpauses].Value.Float64Histogram() - histfactor := downsampleFactor(len(gcpauses.Buckets), maxBuckets) - buckets := downsampleBuckets(gcpauses, histfactor) - - return Heatmap{ - Name: "TODO(set later)", - Title: "Stop-the-world Pause Latencies (Total)", - Type: "heatmap", - UpdateFreq: 5, - Colorscale: PinkShades, - Buckets: floatseq(len(buckets)), - CustomData: buckets, - Hover: HeapmapHover{ - YName: "pause duration", - YUnit: "duration", - ZName: "pauses", - }, - Layout: HeatmapLayout{ - YAxis: HeatmapYaxis{ - Title: "pause duration", - TickMode: "array", - TickVals: []float64{6, 13, 20, 26, 33, 39.5, 46, 53, 60, 66, 73, 79, 86}, - TickText: []float64{1e-7, 1e-6, 1e-5, 1e-4, 1e-3, 5e-3, 1e-2, 5e-2, 1e-1, 5e-1, 1, 5, 10}, - }, - }, - InfoText: `This heatmap shows the distribution of individual GC-related stop-the-world pause latencies. -This is the time from deciding to stop the world until the world is started again. -Some of this time is spent getting all threads to stop (this is measured directly in /sched/pauses/stopping/gc:seconds), during which some threads may still be running. -Uses /sched/pauses/total/gc:seconds.`, - } -} - -func otherTotalPausesLayout(samples []metrics.Sample) Heatmap { - idxtotalother := metricIdx["/sched/pauses/total/other:seconds"] - - totalother := samples[idxtotalother].Value.Float64Histogram() - histfactor := downsampleFactor(len(totalother.Buckets), maxBuckets) - buckets := downsampleBuckets(totalother, histfactor) - - return Heatmap{ - Name: "TODO(set later)", - Title: "Stop-the-world Pause Latencies (Other)", - Type: "heatmap", - UpdateFreq: 5, - Colorscale: PinkShades, - Buckets: floatseq(len(buckets)), - CustomData: buckets, - Hover: HeapmapHover{ - YName: "pause duration", - YUnit: "duration", - ZName: "pauses", - }, - Layout: HeatmapLayout{ - YAxis: HeatmapYaxis{ - Title: "pause duration", - TickMode: "array", - TickVals: []float64{6, 13, 20, 26, 33, 39.5, 46, 53, 60, 66, 73, 79, 86}, - TickText: []float64{1e-7, 1e-6, 1e-5, 1e-4, 1e-3, 5e-3, 1e-2, 5e-2, 1e-1, 5e-1, 1, 5, 10}, - }, - }, - InfoText: `This heatmap shows the distribution of individual non-GC-related stop-the-world pause latencies. -This is the time from deciding to stop the world until the world is started again. -Some of this time is spent getting all threads to stop (measured directly in /sched/pauses/stopping/other:seconds). -Uses /sched/pauses/total/other:seconds.`, - } -} - -func gcStoppingPausesLayout(samples []metrics.Sample) Heatmap { - idxstoppinggc := metricIdx["/sched/pauses/stopping/gc:seconds"] - - stoppinggc := samples[idxstoppinggc].Value.Float64Histogram() - histfactor := downsampleFactor(len(stoppinggc.Buckets), maxBuckets) - buckets := downsampleBuckets(stoppinggc, histfactor) - - return Heatmap{ - Name: "TODO(set later)", - Title: "Stop-the-world Stopping Latencies (GC)", - Type: "heatmap", - UpdateFreq: 5, - Colorscale: PinkShades, - Buckets: floatseq(len(buckets)), - CustomData: buckets, - Hover: HeapmapHover{ - YName: "stopping duration", - YUnit: "duration", - ZName: "pauses", - }, - Layout: HeatmapLayout{ - YAxis: HeatmapYaxis{ - Title: "stopping duration", - TickMode: "array", - TickVals: []float64{6, 13, 20, 26, 33, 39.5, 46, 53, 60, 66, 73, 79, 86}, - TickText: []float64{1e-7, 1e-6, 1e-5, 1e-4, 1e-3, 5e-3, 1e-2, 5e-2, 1e-1, 5e-1, 1, 5, 10}, - }, - }, - InfoText: `This heatmap shows the distribution of individual GC-related stop-the-world stopping latencies. -This is the time it takes from deciding to stop the world until all Ps are stopped. -During this time, some threads may be executing. -Uses /sched/pauses/stopping/gc:seconds.`, - } -} - -func otherStoppingPausesLayout(samples []metrics.Sample) Heatmap { - idxstoppingother := metricIdx["/sched/pauses/stopping/other:seconds"] - - stoppingother := samples[idxstoppingother].Value.Float64Histogram() - histfactor := downsampleFactor(len(stoppingother.Buckets), maxBuckets) - buckets := downsampleBuckets(stoppingother, histfactor) - - return Heatmap{ - Name: "TODO(set later)", - Title: "Stop-the-world Stopping Latencies (Other)", - Type: "heatmap", - UpdateFreq: 5, - Colorscale: PinkShades, - Buckets: floatseq(len(buckets)), - CustomData: buckets, - Hover: HeapmapHover{ - YName: "stopping duration", - YUnit: "duration", - ZName: "pauses", - }, - Layout: HeatmapLayout{ - YAxis: HeatmapYaxis{ - Title: "stopping duration", - TickMode: "array", - TickVals: []float64{6, 13, 20, 26, 33, 39.5, 46, 53, 60, 66, 73, 79, 86}, - TickText: []float64{1e-7, 1e-6, 1e-5, 1e-4, 1e-3, 5e-3, 1e-2, 5e-2, 1e-1, 5e-1, 1, 5, 10}, - }, - }, - InfoText: `This heatmap shows the distribution of individual non-GC-related stop-the-world stopping latencies. -This is the time it takes from deciding to stop the world until all Ps are stopped. -This is a subset of the total non-GC-related stop-the-world time. During this time, some threads may be executing. -Uses /sched/pauses/stopping/other:seconds.`, - } -} diff --git a/internal/plot/list.go b/internal/plot/list.go new file mode 100644 index 00000000..39c06cdd --- /dev/null +++ b/internal/plot/list.go @@ -0,0 +1,156 @@ +package plot + +import ( + "encoding/json" + "fmt" + "io" + "runtime/debug" + "runtime/metrics" + "slices" + "sync" + "time" +) + +// IsReservedPlotName reports whether that name is reserved for Statsviz plots +// and thus can't be used for a user plot. +func IsReservedPlotName(name string) bool { + if name == "timestamp" || name == "lastgc" { + return true + } + registry := reg() + return slices.ContainsFunc(registry.descriptions, func(pd description) bool { + return nameFromLayout(pd.layout) == name + }) +} + +func nameFromLayout(layout any) string { + switch layout := layout.(type) { + case Scatter: + return layout.Name + case Heatmap: + return layout.Name + default: + panic(fmt.Sprintf("unknown plot layout type %T", layout)) + } +} + +// getvalues extracts, from a sample of runtime metrics, a slice with all +// the metrics necessary for a single plot. +type getvalues func(time.Time, []metrics.Sample) any + +// List holds all the plots that statsviz knows about. Some plots might be +// disabled, if they rely on metrics that are unknown to the current Go version. +type List struct { + rtPlots []runtimePlot + userPlots []UserPlot + + once sync.Once // ensure Config is built once + cfg *Config + + reg *registry +} + +type runtimePlot struct { + name string + getvals getvalues + layout any // Scatter | Heatmap +} + +func NewList(userPlots []UserPlot) (*List, error) { + if name := hasDuplicatePlotNames(userPlots); name != "" { + return nil, fmt.Errorf("duplicate plot name %s", name) + } + + return &List{reg: reg(), userPlots: userPlots}, nil +} + +func (pl *List) enabledPlots() []runtimePlot { + plots := make([]runtimePlot, 0, len(pl.reg.descriptions)) + + for _, plot := range pl.reg.descriptions { + plots = append(plots, runtimePlot{ + name: nameFromLayout(plot.layout), + getvals: plot.getvalues(), + layout: plot.layout, + }) + } + + return plots +} + +func (pl *List) Config() *Config { + pl.once.Do(func() { + pl.rtPlots = pl.enabledPlots() + + layouts := make([]any, len(pl.rtPlots)) + for i := range pl.rtPlots { + layouts[i] = pl.rtPlots[i].layout + } + + pl.cfg = &Config{ + Events: []string{"lastgc"}, + Series: layouts, + } + + // User plots go at the back. + for i := range pl.userPlots { + pl.cfg.Series = append(pl.cfg.Series, pl.userPlots[i].Layout()) + } + }) + return pl.cfg +} + +// WriteTo writes into w a JSON object containing the data points for all plots +// at the current instant. Return the number of written plots. +func (pl *List) WriteTo(w io.Writer) (int64, error) { + samples := pl.reg.read() + + // lastgc time series is used as source to represent garbage collection + // timestamps as vertical bars on certain plots. + gcStats := debug.GCStats{} + debug.ReadGCStats(&gcStats) + + m := map[string]any{ + // Javascript timestamps are in milliseconds. + "lastgc": []int64{gcStats.LastGC.UnixMilli()}, + } + now := time.Now() + for _, p := range pl.rtPlots { + m[p.name] = p.getvals(now, samples) + } + + for i := range pl.userPlots { + up := &pl.userPlots[i] + switch { + case up.Scatter != nil: + vals := make([]float64, len(up.Scatter.Funcs)) + for i := range up.Scatter.Funcs { + vals[i] = up.Scatter.Funcs[i]() + } + m[up.Scatter.Plot.Name] = vals + case up.Heatmap != nil: + panic("unimplemented") + } + } + + type data struct { + Series map[string]any `json:"series"` + Timestamp int64 `json:"timestamp"` + } + + if err := json.NewEncoder(w).Encode(struct { + Event string `json:"event"` + Data data `json:"data"` + }{ + Event: "metrics", + Data: data{ + Series: m, + Timestamp: now.UnixMilli(), + }, + }); err != nil { + return 0, fmt.Errorf("failed to write/convert metrics values to json: %v", err) + } + + nplots := int64(len(pl.rtPlots) + len(pl.userPlots)) + return nplots, nil +} diff --git a/internal/plot/plot_cgo.go b/internal/plot/plot_cgo.go new file mode 100644 index 00000000..f135534c --- /dev/null +++ b/internal/plot/plot_cgo.go @@ -0,0 +1,41 @@ +package plot + +import ( + "runtime/metrics" + "time" +) + +var _ = register(description{ + metrics: []string{ + "/cgo/go-to-c-calls:calls", + }, + getvalues: func() getvalues { + // TODO also show cgo calls per second ? + deltacalls := delta[uint64]() + + return func(_ time.Time, samples []metrics.Sample) any { + calls := samples[idx_cgo_go_to_c_calls_calls].Value.Uint64() + + return []uint64{deltacalls(calls)} + } + }, + layout: Scatter{ + Name: "cgo", + Tags: []string{tagMisc}, + Title: "CGO Calls", + Type: "bar", + Layout: ScatterLayout{ + Yaxis: ScatterYAxis{ + Title: "calls", + }, + }, + Subplots: []Subplot{ + { + Name: "calls from go to c", + Unitfmt: "%{y}", + Color: "red", + }, + }, + InfoText: "Shows the count of calls made from Go to C by the current process, per unit of time. Uses /cgo/go-to-c-calls:calls", + }, +}) diff --git a/internal/plot/plot_cpu_gc.go b/internal/plot/plot_cpu_gc.go new file mode 100644 index 00000000..4fc4e9c7 --- /dev/null +++ b/internal/plot/plot_cpu_gc.go @@ -0,0 +1,64 @@ +package plot + +import ( + "runtime/metrics" + "time" +) + +var _ = register(description{ + metrics: []string{ + "/cpu/classes/gc/mark/assist:cpu-seconds", + "/cpu/classes/gc/mark/dedicated:cpu-seconds", + "/cpu/classes/gc/mark/idle:cpu-seconds", + "/cpu/classes/gc/pause:cpu-seconds", + }, + getvalues: func() getvalues { + rateassist := rate[float64]() + ratededicated := rate[float64]() + rateidle := rate[float64]() + ratepause := rate[float64]() + + return func(now time.Time, samples []metrics.Sample) any { + assist := samples[idx_cpu_classes_gc_mark_assist_cpu_seconds].Value.Float64() + dedicated := samples[idx_cpu_classes_gc_mark_dedicated_cpu_seconds].Value.Float64() + idle := samples[idx_cpu_classes_gc_mark_idle_cpu_seconds].Value.Float64() + pause := samples[idx_cpu_classes_gc_pause_cpu_seconds].Value.Float64() + + return []float64{ + rateassist(now, assist), + ratededicated(now, dedicated), + rateidle(now, idle), + ratepause(now, pause), + } + } + }, + layout: Scatter{ + Name: "cpu-gc", + Tags: []tag{tagCPU, tagGC}, + Title: "CPU (Garbage Collector)", + Type: "scatter", + Events: "lastgc", + Layout: ScatterLayout{ + Yaxis: ScatterYAxis{ + Title: "cpu-seconds per seconds", + TickSuffix: "s", + }, + }, + Subplots: []Subplot{ + {Name: "mark assist", Unitfmt: "%{y:.4s}s"}, + {Name: "mark dedicated", Unitfmt: "%{y:.4s}s"}, + {Name: "mark idle", Unitfmt: "%{y:.4s}s"}, + {Name: "pause", Unitfmt: "%{y:.4s}s"}, + }, + + InfoText: `Cumulative metrics are converted to rates by Statsviz so as to be more easily comparable and readable. +All this metrics are overestimates, and not directly comparable to system CPU time measurements. Compare only with other /cpu/classes metrics. + +mark assist is /cpu/classes/gc/mark/assist, estimated total CPU time goroutines spent performing GC tasks to assist the GC and prevent it from falling behind the application. +mark dedicated is /cpu/classes/gc/mark/dedicated, Estimated total CPU time spent performing GC tasks on processors (as defined by GOMAXPROCS) dedicated to those tasks. +mark idle is /cpu/classes/gc/mark/idle, estimated total CPU time spent performing GC tasks on spare CPU resources that the Go scheduler could not otherwise find a use for. +pause is /cpu/classes/gc/pause, estimated total CPU time spent with the application paused by the GC. + +All metrics are rates in CPU-seconds per second.`, + }, +}) diff --git a/internal/plot/plot_cpu_overall.go b/internal/plot/plot_cpu_overall.go new file mode 100644 index 00000000..3bf583b5 --- /dev/null +++ b/internal/plot/plot_cpu_overall.go @@ -0,0 +1,68 @@ +package plot + +import ( + "runtime/metrics" + "time" +) + +var _ = register(description{ + metrics: []string{ + "/cpu/classes/user:cpu-seconds", + "/cpu/classes/scavenge/total:cpu-seconds", + "/cpu/classes/idle:cpu-seconds", + "/cpu/classes/gc/total:cpu-seconds", + "/cpu/classes/total:cpu-seconds", + }, + getvalues: func() getvalues { + rateuser := rate[float64]() + ratescavenge := rate[float64]() + rateidle := rate[float64]() + rategctotal := rate[float64]() + ratetotal := rate[float64]() + + return func(now time.Time, samples []metrics.Sample) any { + user := samples[idx_cpu_classes_user_cpu_seconds].Value.Float64() + scavenge := samples[idx_cpu_classes_scavenge_total_cpu_seconds].Value.Float64() + idle := samples[idx_cpu_classes_idle_cpu_seconds].Value.Float64() + gctotal := samples[idx_cpu_classes_gc_total_cpu_seconds].Value.Float64() + total := samples[idx_cpu_classes_total_cpu_seconds].Value.Float64() + + return []float64{ + rateuser(now, user), + ratescavenge(now, scavenge), + rateidle(now, idle), + rategctotal(now, gctotal), + ratetotal(now, total), + } + } + }, + layout: Scatter{ + Name: "cpu-overall", + Tags: []tag{tagCPU}, + Title: "CPU (Overall)", + Type: "bar", + Events: "lastgc", + Layout: ScatterLayout{ + BarMode: "stack", + Yaxis: ScatterYAxis{ + Title: "cpu-seconds / second", + TickSuffix: "s", + }, + }, + Subplots: []Subplot{ + {Unitfmt: "%{y:.4s}s", Type: "bar", Name: "user"}, + {Unitfmt: "%{y:.4s}s", Type: "bar", Name: "scavenge"}, + {Unitfmt: "%{y:.4s}s", Type: "bar", Name: "idle"}, + {Unitfmt: "%{y:.4s}s", Type: "bar", Name: "gc total"}, + {Unitfmt: "%{y:.4s}s", Type: "scatter", Name: "total"}, + }, + InfoText: `Shows the fraction of CPU spent in your code vs. runtime vs. wasted. Helps track overall utilization and potential headroom. +user is the rate of /cpu/classes/user:cpu-seconds, the CPU time spent running user Go code. +scavenge is the rate of /cpu/classes/scavenge:cpu-seconds, the CPU time spent performing tasks that return unused memory to the OS. +idle is the rate of /cpu/classes/idle:cpu-seconds, the CPU time spent performing GC tasks on spare CPU resources that the Go scheduler could not otherwise find a use for. +gc total is the rate of /cpu/classes/gc/total:cpu-seconds, the CPU time spent performing GC tasks (sum of all metrics in /cpu/classes/gc) +total is the rate of /cpu/classes/total:cpu-seconds, the available CPU time for user Go code or the Go runtime, as defined by GOMAXPROCS. In other words, GOMAXPROCS integrated over the wall-clock duration this process has been executing for. + +All metrics are rates in CPU-seconds per second.`, + }, +}) diff --git a/internal/plot/plot_cpu_scavenger.go b/internal/plot/plot_cpu_scavenger.go new file mode 100644 index 00000000..8029ad5f --- /dev/null +++ b/internal/plot/plot_cpu_scavenger.go @@ -0,0 +1,57 @@ +package plot + +import ( + "runtime/metrics" + "time" +) + +var _ = register(description{ + metrics: []string{ + "/cpu/classes/scavenge/assist:cpu-seconds", + "/cpu/classes/scavenge/background:cpu-seconds", + }, + getvalues: func() getvalues { + rateassist := rate[float64]() + ratebackground := rate[float64]() + + return func(now time.Time, samples []metrics.Sample) any { + assist := samples[idx_cpu_classes_scavenge_assist_cpu_seconds].Value.Float64() + background := samples[idx_cpu_classes_scavenge_background_cpu_seconds].Value.Float64() + return []float64{ + rateassist(now, assist), + ratebackground(now, background), + } + } + }, + layout: Scatter{ + Name: "cpu-scavenger", + Tags: []tag{tagCPU, tagGC}, + Title: "CPU (Scavenger)", + Type: "bar", + Events: "lastgc", + Layout: ScatterLayout{ + BarMode: "stack", + Yaxis: ScatterYAxis{ + Title: "cpu-seconds / second", + TickSuffix: "s", + }, + }, + Subplots: []Subplot{ + { + Name: "assist", + Unitfmt: "%{y:.4s}s", + Type: "bar", + }, + { + Name: "background", + Unitfmt: "%{y:.4s}s", + Type: "bar", + }, + }, + InfoText: `Breakdown of how the GC scavenger returns memory to the OS (eagerly vs background). +assist is the rate of /cpu/classes/scavenge/assist, the CPU time spent returning unused memory eagerly in response to memory pressure. +background is the rate of /cpu/classes/scavenge/background, the CPU time spent performing background tasks to return unused memory to the OS. + +Both metrics are rates in CPU-seconds per second.`, + }, +}) diff --git a/internal/plot/plot_gc.go b/internal/plot/plot_gc.go new file mode 100644 index 00000000..b06b742d --- /dev/null +++ b/internal/plot/plot_gc.go @@ -0,0 +1,61 @@ +package plot + +import ( + "math" + "runtime/metrics" + "time" +) + +var _ = register(description{ + metrics: []string{ + "/gc/gomemlimit:bytes", + "/gc/heap/live:bytes", + "/gc/heap/goal:bytes", + "/memory/classes/total:bytes", + "/memory/classes/heap/released:bytes", + }, + getvalues: func() getvalues { + return func(_ time.Time, samples []metrics.Sample) any { + memLimit := samples[idx_gc_gomemlimit_bytes].Value.Uint64() + heapLive := samples[idx_gc_heap_live_bytes].Value.Uint64() + heapGoal := samples[idx_gc_heap_goal_bytes].Value.Uint64() + memTotal := samples[idx_memory_classes_total_bytes].Value.Uint64() + heapReleased := samples[idx_memory_classes_heap_released_bytes].Value.Uint64() + + if memLimit == math.MaxInt64 { + memLimit = 0 + } + + return []uint64{ + memLimit, + memTotal - heapReleased, + heapLive, + heapGoal, + } + } + }, + layout: Scatter{ + Name: "garbage collection", + Tags: []tag{tagGC}, + Title: "GC Memory Summary", + Type: "scatter", + Events: "lastgc", + Layout: ScatterLayout{ + Yaxis: ScatterYAxis{ + Title: "bytes", + TickSuffix: "B", + }, + }, + Subplots: []Subplot{ + {Name: "memory limit", Unitfmt: "%{y:.4s}B"}, + {Name: "in-use memory", Unitfmt: "%{y:.4s}B"}, + {Name: "heap live", Unitfmt: "%{y:.4s}B"}, + {Name: "heap goal", Unitfmt: "%{y:.4s}B"}, + }, + InfoText: ` +Memory limit is /gc/gomemlimit:bytes, the Go runtime memory limit configured by the user (via GOMEMLIMIT or debug.SetMemoryLimt), otherwise 0. +In-use memory is the total mapped memory minus released heap memory (/memory/classes/total - /memory/classes/heap/released). +Heap live is /gc/heap/live:bytes, heap memory occupied by live objects. +Heap goal is /gc/heap/goal:bytes, the heap size target at the end of each GC cycle.`, + }, +}) diff --git a/internal/plot/plot_gc_cycles.go b/internal/plot/plot_gc_cycles.go new file mode 100644 index 00000000..1b55a3ff --- /dev/null +++ b/internal/plot/plot_gc_cycles.go @@ -0,0 +1,45 @@ +package plot + +import ( + "runtime/metrics" + "time" +) + +var _ = register(description{ + metrics: []string{ + "/gc/cycles/automatic:gc-cycles", + "/gc/cycles/forced:gc-cycles", + "/gc/cycles/total:gc-cycles", + }, + getvalues: func() getvalues { + deltaauto := delta[uint64]() + deltaforced := delta[uint64]() + deltatotal := delta[uint64]() + + return func(_ time.Time, samples []metrics.Sample) any { + auto := samples[idx_gc_cycles_automatic_gc_cycles].Value.Uint64() + forced := samples[idx_gc_cycles_forced_gc_cycles].Value.Uint64() + total := samples[idx_gc_cycles_total_gc_cycles].Value.Uint64() + + return []uint64{deltaauto(auto), deltaforced(forced), deltatotal(total)} + } + }, + layout: Scatter{ + Name: "gc-cycles", + Tags: []tag{tagGC}, + Title: "Completed GC Cycles", + Type: "bar", + Layout: ScatterLayout{ + BarMode: "stack", + Yaxis: ScatterYAxis{ + Title: "cycles", + }, + }, + Subplots: []Subplot{ + {Unitfmt: "%{y}", Type: "bar", Name: "automatic"}, + {Unitfmt: "%{y}", Type: "bar", Name: "forced"}, + {Unitfmt: "%{y}", Type: "scatter", Name: "total"}, + }, + InfoText: `Number of completed GC cycles, either forced of generated by the Go runtime.`, + }, +}) diff --git a/internal/plot/plot_gc_scan.go b/internal/plot/plot_gc_scan.go new file mode 100644 index 00000000..39793329 --- /dev/null +++ b/internal/plot/plot_gc_scan.go @@ -0,0 +1,60 @@ +package plot + +import ( + "runtime/metrics" + "time" +) + +var _ = register(description{ + metrics: []string{ + "/gc/scan/globals:bytes", + "/gc/scan/heap:bytes", + "/gc/scan/stack:bytes", + }, + getvalues: func() getvalues { + return func(_ time.Time, samples []metrics.Sample) any { + globals := samples[idx_gc_scan_globals_bytes].Value.Uint64() + heap := samples[idx_gc_scan_heap_bytes].Value.Uint64() + stack := samples[idx_gc_scan_stack_bytes].Value.Uint64() + + return []uint64{globals, heap, stack} + } + }, + layout: Scatter{ + Name: "gc-scan", + Tags: []tag{tagGC}, + Title: "GC Scan", + Type: "bar", + Events: "lastgc", + Layout: ScatterLayout{ + BarMode: "stack", + Yaxis: ScatterYAxis{ + TickSuffix: "B", + Title: "bytes", + }, + }, + Subplots: []Subplot{ + { + Name: "scannable globals", + Unitfmt: "%{y:.4s}B", + Type: "bar", + }, + { + Name: "scannable heap", + Unitfmt: "%{y:.4s}B", + Type: "bar", + }, + { + Name: "scanned stack", + Unitfmt: "%{y:.4s}B", + Type: "bar", + }, + }, + InfoText: ` +This plot shows the amount of memory that is scannable by the GC. +scannable globals is /gc/scan/globals, the total amount of global variable space that is scannable. +scannable heap is /gc/scan/heap, the total amount of heap space that is scannable. +scanned stack is /gc/scan/stack, the number of bytes of stack that were scanned last GC cycle. +`, + }, +}) diff --git a/internal/plot/plot_goroutines.go b/internal/plot/plot_goroutines.go new file mode 100644 index 00000000..0a27a176 --- /dev/null +++ b/internal/plot/plot_goroutines.go @@ -0,0 +1,59 @@ +//go:build go1.26 + +package plot + +import ( + "runtime/metrics" + "time" +) + +var _ = register(description{ + metrics: []string{ + "/sched/goroutines:goroutines", + "/sched/goroutines-created:goroutines", + "/sched/goroutines/not-in-go:goroutines", + "/sched/goroutines/runnable:goroutines", + "/sched/goroutines/running:goroutines", + "/sched/goroutines/waiting:goroutines", + }, + getvalues: func() getvalues { + deltaCreated := delta[uint64]() + + return func(_ time.Time, samples []metrics.Sample) any { + created := deltaCreated(samples[idx_sched_goroutines_created_goroutines].Value.Uint64()) + goroutines := samples[idx_sched_goroutines_goroutines].Value.Uint64() + notInGo := samples[idx_sched_goroutines_not_in_go_goroutines].Value.Uint64() + runnable := samples[idx_sched_goroutines_runnable_goroutines].Value.Uint64() + running := samples[idx_sched_goroutines_running_goroutines].Value.Uint64() + waiting := samples[idx_sched_goroutines_waiting_goroutines].Value.Uint64() + + return []uint64{created, goroutines, notInGo, runnable, running, waiting} + } + }, + + layout: Scatter{ + Name: "goroutines", + Tags: []tag{tagScheduler}, + Title: "Goroutines", + Type: "scatter", + Layout: ScatterLayout{ + Yaxis: ScatterYAxis{ + Title: "goroutines", + }, + }, + Subplots: []Subplot{ + {Name: "created", Unitfmt: "%{y}", Type: "bar"}, + {Name: "goroutines", Unitfmt: "%{y}"}, + {Name: "not in Go", Unitfmt: "%{y}"}, + {Name: "runnable", Unitfmt: "%{y}"}, + {Name: "running", Unitfmt: "%{y}"}, + {Name: "waiting", Unitfmt: "%{y}"}, + }, + InfoText: `Goroutines is /sched/goroutines, the count of live goroutines. +Created is the delta of /sched/goroutines-created, the cumulative number of created goroutines. +Not in Go is /sched/goroutines/not-in-go, the approximate count of goroutines running or blocked in a system call or cgo call. +Runnable is /sched/goroutines/runnable, the approximate count of goroutines ready to execute, but not executing. +Running is /sched/goroutines/running, the approximate count of goroutines executing. +Waiting is /sched/goroutines/waiting, the approximate count of goroutines waiting on a resource (I/O or sync primitives).`, + }, +}) diff --git a/internal/plot/plot_gs_stack_size.go b/internal/plot/plot_gs_stack_size.go new file mode 100644 index 00000000..eed96d9f --- /dev/null +++ b/internal/plot/plot_gs_stack_size.go @@ -0,0 +1,33 @@ +package plot + +import ( + "runtime/metrics" + "time" +) + +var _ = register(description{ + metrics: []string{ + "/gc/stack/starting-size:bytes", + }, + getvalues: func() getvalues { + return func(_ time.Time, samples []metrics.Sample) any { + stackSize := samples[idx_gc_stack_starting_size_bytes].Value.Uint64() + return []uint64{stackSize} + } + }, + layout: Scatter{ + Name: "gc-stack-size", + Tags: []tag{tagGC}, + Title: "Goroutines stack starting size", + Type: "scatter", + Layout: ScatterLayout{ + Yaxis: ScatterYAxis{ + Title: "bytes", + }, + }, + Subplots: []Subplot{ + {Name: "new goroutines stack size", Unitfmt: "%{y:.4s}B"}, + }, + InfoText: "Shows the stack size of new goroutines, uses /gc/stack/starting-size:bytes", + }, +}) diff --git a/internal/plot/plot_heap_allocs.go b/internal/plot/plot_heap_allocs.go new file mode 100644 index 00000000..bf50f2ea --- /dev/null +++ b/internal/plot/plot_heap_allocs.go @@ -0,0 +1,56 @@ +package plot + +import ( + "runtime/metrics" + "time" +) + +var _ = register(description{ + metrics: []string{ + "/gc/heap/allocs:objects", + "/gc/heap/frees:objects", + }, + getvalues: func() getvalues { + deltaallocs := delta[uint64]() + deltafrees := delta[uint64]() + + rateallocs := rate[uint64]() + ratefrees := rate[uint64]() + + return func(now time.Time, samples []metrics.Sample) any { + curallocs := samples[idx_gc_heap_allocs_objects].Value.Uint64() + curfrees := samples[idx_gc_heap_frees_objects].Value.Uint64() + + return []float64{ + rateallocs(now, deltaallocs(curallocs)), + ratefrees(now, deltafrees(curfrees)), + } + } + }, + layout: Scatter{ + Name: "alloc-free-rate", + Tags: []tag{tagGC}, + Title: "Heap Allocation & Free Rates", + Type: "scatter", + Layout: ScatterLayout{ + Yaxis: ScatterYAxis{ + Title: "objects / second", + }, + }, + Subplots: []Subplot{ + { + Name: "allocs/sec", + Unitfmt: "%{y:.4s}", + Color: RGBString(66, 133, 244), + }, + { + Name: "frees/sec", + Unitfmt: "%{y:.4s}", + Color: RGBString(219, 68, 55), + }, + }, + InfoText: ` +Allocations per second is the delta, per second, of the cumulative /gc/heap/allocs:objects metric. +Frees per second is the delta, per second, of the cumulative /gc/heap/frees:objects metric.`, + }, +}) diff --git a/internal/plot/plot_heap_details.go b/internal/plot/plot_heap_details.go new file mode 100644 index 00000000..bd4370b0 --- /dev/null +++ b/internal/plot/plot_heap_details.go @@ -0,0 +1,63 @@ +package plot + +import ( + "runtime/metrics" + "time" +) + +var _ = register(description{ + metrics: []string{ + "/memory/classes/heap/objects:bytes", + "/memory/classes/heap/unused:bytes", + "/memory/classes/heap/free:bytes", + "/memory/classes/heap/released:bytes", + "/memory/classes/heap/stacks:bytes", + "/gc/heap/goal:bytes", + }, + getvalues: func() getvalues { + return func(_ time.Time, samples []metrics.Sample) any { + heapObjects := samples[idx_memory_classes_heap_objects_bytes].Value.Uint64() + heapUnused := samples[idx_memory_classes_heap_unused_bytes].Value.Uint64() + heapFree := samples[idx_memory_classes_heap_free_bytes].Value.Uint64() + heapReleased := samples[idx_memory_classes_heap_released_bytes].Value.Uint64() + heapStacks := samples[idx_memory_classes_heap_stacks_bytes].Value.Uint64() + nextGC := samples[idx_gc_heap_goal_bytes].Value.Uint64() + + heapIdle := heapReleased + heapFree + heapInUse := heapObjects + heapUnused + heapSys := heapInUse + heapIdle + + return []uint64{ + heapSys, + heapObjects, + heapStacks, + nextGC, + } + } + }, + + layout: Scatter{ + Name: "heap (details)", + Tags: []tag{tagGC}, + Title: "Heap (details)", + Type: "scatter", + Events: "lastgc", + Layout: ScatterLayout{ + Yaxis: ScatterYAxis{ + Title: "bytes", + TickSuffix: "B", + }, + }, + Subplots: []Subplot{ + {Unitfmt: "%{y:.4s}B", Name: "heap sys"}, + {Unitfmt: "%{y:.4s}B", Name: "heap objects"}, + {Unitfmt: "%{y:.4s}B", Name: "heap stacks"}, + {Unitfmt: "%{y:.4s}B", Name: "heap goal"}, + }, + InfoText: ` +Heap sys is /memory/classes/heap/{objects + unused + released + free}. It's an estimate of all the heap memory obtained from the OS. +Heap objects is /memory/classes/heap/objects, the memory occupied by live objects and dead objects that have not yet been marked free by the GC. +Heap stacks is /memory/classes/heap/stacks, the memory used for stack space. +Heap goal is gc/heap/goal, the heap size target for the end of the GC cycle.`, + }, +}) diff --git a/internal/plot/plot_live_bytes.go b/internal/plot/plot_live_bytes.go new file mode 100644 index 00000000..864f342a --- /dev/null +++ b/internal/plot/plot_live_bytes.go @@ -0,0 +1,41 @@ +package plot + +import ( + "runtime/metrics" + "time" +) + +var _ = register(description{ + metrics: []string{ + "/gc/heap/allocs:bytes", + "/gc/heap/frees:bytes", + }, + getvalues: func() getvalues { + return func(_ time.Time, samples []metrics.Sample) any { + allocBytes := samples[idx_gc_heap_allocs_bytes].Value.Uint64() + freedBytes := samples[idx_gc_heap_frees_bytes].Value.Uint64() + + return []uint64{allocBytes - freedBytes} + } + }, + layout: Scatter{ + Name: "live-bytes", + Tags: []tag{tagGC}, + Title: "Live Bytes in Heap", + Type: "bar", + Events: "lastgc", + Layout: ScatterLayout{ + Yaxis: ScatterYAxis{ + Title: "bytes", + }, + }, + Subplots: []Subplot{ + { + Name: "live bytes", + Unitfmt: "%{y:.4s}B", + Color: RGBString(135, 182, 218), + }, + }, + InfoText: `Live bytes is /gc/heap/allocs - /gc/heap/frees. It's the number of bytes currently allocated (and not yet GC'ec) to the heap by the application.`, + }, +}) diff --git a/internal/plot/plot_live_objects.go b/internal/plot/plot_live_objects.go new file mode 100644 index 00000000..ac02b8fc --- /dev/null +++ b/internal/plot/plot_live_objects.go @@ -0,0 +1,39 @@ +package plot + +import ( + "runtime/metrics" + "time" +) + +var _ = register(description{ + metrics: []string{ + "/gc/heap/objects:objects", + }, + getvalues: func() getvalues { + return func(_ time.Time, samples []metrics.Sample) any { + gcHeapObjects := samples[idx_gc_heap_objects_objects].Value.Uint64() + + return []uint64{gcHeapObjects} + } + }, + layout: Scatter{ + Name: "live-objects", + Tags: []tag{tagGC}, + Title: "Live Objects in Heap", + Type: "bar", + Events: "lastgc", + Layout: ScatterLayout{ + Yaxis: ScatterYAxis{ + Title: "objects", + }, + }, + Subplots: []Subplot{ + { + Name: "live objects", + Unitfmt: "%{y:.4s}", + Color: RGBString(255, 195, 128), + }, + }, + InfoText: `Live objects is /gc/heap/objects. It's the number of objects, live or unswept, occupying heap memory.`, + }, +}) diff --git a/internal/plot/plot_memory_classes.go b/internal/plot/plot_memory_classes.go new file mode 100644 index 00000000..e58f7738 --- /dev/null +++ b/internal/plot/plot_memory_classes.go @@ -0,0 +1,55 @@ +package plot + +import ( + "runtime/metrics" + "time" +) + +var _ = register(description{ + metrics: []string{ + "/memory/classes/os-stacks:bytes", + "/memory/classes/other:bytes", + "/memory/classes/profiling/buckets:bytes", + "/memory/classes/total:bytes", + }, + getvalues: func() getvalues { + return func(_ time.Time, samples []metrics.Sample) any { + osStacks := samples[idx_memory_classes_os_stacks_bytes].Value.Uint64() + other := samples[idx_memory_classes_other_bytes].Value.Uint64() + profBuckets := samples[idx_memory_classes_profiling_buckets_bytes].Value.Uint64() + total := samples[idx_memory_classes_total_bytes].Value.Uint64() + + return []uint64{ + osStacks, + other, + profBuckets, + total, + } + } + }, + layout: Scatter{ + Name: "memory-classes", + Tags: []tag{tagGC}, + Title: "Memory classes", + Type: "scatter", + Events: "lastgc", + Layout: ScatterLayout{ + Yaxis: ScatterYAxis{ + Title: "bytes", + TickSuffix: "B", + }, + }, + Subplots: []Subplot{ + {Unitfmt: "%{y:.4s}B", Name: "os stacks"}, + {Unitfmt: "%{y:.4s}B", Name: "other"}, + {Unitfmt: "%{y:.4s}B", Name: "profiling buckets"}, + {Unitfmt: "%{y:.4s}B", Name: "total"}, + }, + + InfoText: ` +OS stacks is /memory/classes/os-stacks, stack memory allocated by the underlying operating system. +Other is /memory/classes/other, memory used by execution trace buffers, structures for debugging the runtime, finalizer and profiler specials, and more. +Profiling buckets is /memory/classes/profiling/buckets, memory that is used by the stack trace hash map used for profiling. +Total is /memory/classes/total, all memory mapped by the Go runtime into the current process as read-write.`, + }, +}) diff --git a/internal/plot/plot_mspan_mcache.go b/internal/plot/plot_mspan_mcache.go new file mode 100644 index 00000000..b3f8f836 --- /dev/null +++ b/internal/plot/plot_mspan_mcache.go @@ -0,0 +1,50 @@ +package plot + +import ( + "runtime/metrics" + "time" +) + +var _ = register(description{ + metrics: []string{ + "/memory/classes/metadata/mspan/inuse:bytes", + "/memory/classes/metadata/mspan/free:bytes", + "/memory/classes/metadata/mcache/inuse:bytes", + "/memory/classes/metadata/mcache/free:bytes", + }, + getvalues: func() getvalues { + return func(_ time.Time, samples []metrics.Sample) any { + mspanInUse := samples[idx_memory_classes_metadata_mspan_inuse_bytes].Value.Uint64() + mspanSys := samples[idx_memory_classes_metadata_mspan_free_bytes].Value.Uint64() + mcacheInUse := samples[idx_memory_classes_metadata_mcache_inuse_bytes].Value.Uint64() + mcacheSys := samples[idx_memory_classes_metadata_mcache_free_bytes].Value.Uint64() + + return []uint64{mspanInUse, mspanSys, mcacheInUse, mcacheSys} + } + }, + layout: Scatter{ + Name: "mspan-mcache", + Tags: []tag{tagGC}, + Title: "MSpan/MCache", + Type: "scatter", + Events: "lastgc", + Layout: ScatterLayout{ + Yaxis: ScatterYAxis{ + Title: "bytes", + TickSuffix: "B", + }, + }, + Subplots: []Subplot{ + {Unitfmt: "%{y:.4s}B", Name: "mspan in-use"}, + {Unitfmt: "%{y:.4s}B", Name: "mspan free"}, + {Unitfmt: "%{y:.4s}B", Name: "mcache in-use"}, + {Unitfmt: "%{y:.4s}B", Name: "mcache free"}, + }, + InfoText: ` +Mspan in-use is /memory/classes/metadata/mspan/inuse, the memory that is occupied by runtime mspan structures that are currently being used. +Mspan free is /memory/classes/metadata/mspan/free, the memory that is reserved for runtime mspan structures, but not in-use. +Mcache in-use is /memory/classes/metadata/mcache/inuse, the memory that is occupied by runtime mcache structures that are currently being used. +Mcache free is /memory/classes/metadata/mcache/free, the memory that is reserved for runtime mcache structures, but not in-use. +`, + }, +}) diff --git a/internal/plot/plot_mutex_wait.go b/internal/plot/plot_mutex_wait.go new file mode 100644 index 00000000..1a136e41 --- /dev/null +++ b/internal/plot/plot_mutex_wait.go @@ -0,0 +1,46 @@ +package plot + +import ( + "runtime/metrics" + "time" +) + +var _ = register(description{ + metrics: []string{ + "/sync/mutex/wait/total:seconds", + }, + getvalues: func() getvalues { + ratemutexwait := rate[float64]() + + return func(now time.Time, samples []metrics.Sample) any { + mutexwait := ratemutexwait(now, samples[idx_sync_mutex_wait_total_seconds].Value.Float64()) + + return []float64{mutexwait} + } + }, + layout: Scatter{ + Name: "mutex-wait", + Tags: []tag{tagMisc}, + Title: "Mutex wait time", + Type: "bar", + Events: "lastgc", + Layout: ScatterLayout{ + Yaxis: ScatterYAxis{ + Title: "seconds / second", + TickSuffix: "s", + }, + }, + Subplots: []Subplot{ + { + Name: "mutex wait", + Unitfmt: "%{y:.4s}s", + Type: "bar", + }, + }, + + InfoText: `Cumulative metrics are converted to rates by Statsviz so as to be more easily comparable and readable. +mutex wait is /sync/mutex/wait/total, approximate cumulative time goroutines have spent blocked on a sync.Mutex or sync.RWMutex. + +This metric is useful for identifying global changes in lock contention. Collect a mutex or block profile using the runtime/pprof package for more detailed contention data.`, + }, +}) diff --git a/internal/plot/plot_runnable_time.go b/internal/plot/plot_runnable_time.go new file mode 100644 index 00000000..293cb858 --- /dev/null +++ b/internal/plot/plot_runnable_time.go @@ -0,0 +1,55 @@ +package plot + +import ( + "runtime/metrics" + "time" +) + +var _ = register(description{ + metrics: []string{ + "/sched/latencies:seconds", + }, + getvalues: func() getvalues { + histfactor := 0 + counts := [maxBuckets]uint64{} + + return func(_ time.Time, samples []metrics.Sample) any { + hist := samples[idx_sched_latencies_seconds].Value.Float64Histogram() + if histfactor == 0 { + histfactor = downsampleFactor(len(hist.Buckets), maxBuckets) + } + + return downsampleCounts(hist, histfactor, counts[:]) + } + }, + layout: func(samples []metrics.Sample) Heatmap { + hist := samples[idx_sched_latencies_seconds].Value.Float64Histogram() + histfactor := downsampleFactor(len(hist.Buckets), maxBuckets) + buckets := downsampleBuckets(hist, histfactor) + + return Heatmap{ + Name: "runnable-time", + Tags: []tag{tagScheduler}, + Title: "Time Goroutines Spend in 'Runnable' state", + Type: "heatmap", + UpdateFreq: 5, + Colorscale: GreenShades, + Buckets: floatseq(len(buckets)), + CustomData: buckets, + Hover: HeapmapHover{ + YName: "duration", + YUnit: "duration", + ZName: "goroutines", + }, + Layout: HeatmapLayout{ + YAxis: HeatmapYaxis{ + Title: "duration", + TickMode: "array", + TickVals: []float64{6, 13, 20, 26, 33, 39.5, 46, 53, 60, 66, 73, 79, 86}, + TickText: []float64{1e-7, 1e-6, 1e-5, 1e-4, 1e-3, 5e-3, 1e-2, 5e-2, 1e-1, 5e-1, 1, 5, 10}, + }, + }, + InfoText: `This heatmap shows the distribution of the time goroutines have spent in the scheduler in a runnable state before actually running, uses /sched/latencies:seconds.`, + } + }, +}) diff --git a/internal/plot/plot_size_classes.go b/internal/plot/plot_size_classes.go new file mode 100644 index 00000000..7332642e --- /dev/null +++ b/internal/plot/plot_size_classes.go @@ -0,0 +1,71 @@ +package plot + +import ( + "runtime/metrics" + "time" +) + +var _ = register(description{ + metrics: []string{ + "/gc/heap/allocs-by-size:bytes", + "/gc/heap/frees-by-size:bytes", + }, + getvalues: func() getvalues { + var sizeClasses []uint64 + + return func(_ time.Time, samples []metrics.Sample) any { + allocsBySize := samples[idx_gc_heap_allocs_by_size_bytes].Value.Float64Histogram() + freesBySize := samples[idx_gc_heap_frees_by_size_bytes].Value.Float64Histogram() + + if sizeClasses == nil { + sizeClasses = make([]uint64, len(allocsBySize.Counts)) + } + + for i := range sizeClasses { + sizeClasses[i] = allocsBySize.Counts[i] - freesBySize.Counts[i] + } + + return sizeClasses + } + }, + layout: func(samples []metrics.Sample) Heatmap { + // Perform a sanity check on the number of buckets on the 'allocs' and + // 'frees' size classes histograms. Statsviz plots a single histogram based + // on those 2 so we want them to have the same number of buckets, which + // should be true. + allocsBySize := samples[idx_gc_heap_allocs_by_size_bytes].Value.Float64Histogram() + freesBySize := samples[idx_gc_heap_frees_by_size_bytes].Value.Float64Histogram() + if len(allocsBySize.Buckets) != len(freesBySize.Buckets) { + panic("different number of buckets in allocs and frees size classes histograms") + } + + // No downsampling for the size classes histogram (factor=1) but we still + // need to adapt boundaries for plotly heatmaps. + buckets := downsampleBuckets(allocsBySize, 1) + + return Heatmap{ + Name: "size-classes", + Tags: []tag{tagGC}, + Title: "Size Classes", + Type: "heatmap", + UpdateFreq: 5, + Colorscale: BlueShades, + Buckets: floatseq(len(buckets)), + CustomData: buckets, + Hover: HeapmapHover{ + YName: "size class", + YUnit: "bytes", + ZName: "objects", + }, + InfoText: `This heatmap shows the distribution of size classes, using /gc/heap/allocs-by-size and /gc/heap/frees-by-size.`, + Layout: HeatmapLayout{ + YAxis: HeatmapYaxis{ + Title: "size class", + TickMode: "array", + TickVals: []float64{1, 9, 17, 25, 31, 37, 43, 50, 58, 66}, + TickText: []float64{1 << 4, 1 << 7, 1 << 8, 1 << 9, 1 << 10, 1 << 11, 1 << 12, 1 << 13, 1 << 14, 1 << 15}, + }, + }, + } + }, +}) diff --git a/internal/plot/plot_stopping_pauses_gc.go b/internal/plot/plot_stopping_pauses_gc.go new file mode 100644 index 00000000..8f6515a1 --- /dev/null +++ b/internal/plot/plot_stopping_pauses_gc.go @@ -0,0 +1,58 @@ +package plot + +import ( + "runtime/metrics" + "time" +) + +var _ = register(description{ + metrics: []string{ + "/sched/pauses/stopping/gc:seconds", + }, + getvalues: func() getvalues { + histfactor := 0 + counts := [maxBuckets]uint64{} + + return func(_ time.Time, samples []metrics.Sample) any { + hist := samples[idx_sched_pauses_stopping_gc_seconds].Value.Float64Histogram() + if histfactor == 0 { + histfactor = downsampleFactor(len(hist.Buckets), maxBuckets) + } + + return downsampleCounts(hist, histfactor, counts[:]) + } + }, + layout: func(samples []metrics.Sample) Heatmap { + hist := samples[idx_sched_pauses_stopping_gc_seconds].Value.Float64Histogram() + histfactor := downsampleFactor(len(hist.Buckets), maxBuckets) + buckets := downsampleBuckets(hist, histfactor) + + return Heatmap{ + Name: "stopping-pauses-gc", + Tags: []tag{tagScheduler, tagGC}, + Title: "Stop-the-world Stopping Latencies (GC)", + Type: "heatmap", + UpdateFreq: 5, + Colorscale: GreenShades, + Buckets: floatseq(len(buckets)), + CustomData: buckets, + Hover: HeapmapHover{ + YName: "stopping duration", + YUnit: "duration", + ZName: "pauses", + }, + Layout: HeatmapLayout{ + YAxis: HeatmapYaxis{ + Title: "stopping duration", + TickMode: "array", + TickVals: []float64{6, 13, 20, 26, 33, 39.5, 46, 53, 60, 66, 73, 79, 86}, + TickText: []float64{1e-7, 1e-6, 1e-5, 1e-4, 1e-3, 5e-3, 1e-2, 5e-2, 1e-1, 5e-1, 1, 5, 10}, + }, + }, + InfoText: `This heatmap shows the distribution of individual GC-related stop-the-world stopping latencies. +This is the time it takes from deciding to stop the world until all Ps are stopped. +During this time, some threads may be executing. +Uses /sched/pauses/stopping/gc:seconds.`, + } + }, +}) diff --git a/internal/plot/plot_stopping_pauses_other.go b/internal/plot/plot_stopping_pauses_other.go new file mode 100644 index 00000000..78902ac5 --- /dev/null +++ b/internal/plot/plot_stopping_pauses_other.go @@ -0,0 +1,58 @@ +package plot + +import ( + "runtime/metrics" + "time" +) + +var _ = register(description{ + metrics: []string{ + "/sched/pauses/stopping/other:seconds", + }, + getvalues: func() getvalues { + histfactor := 0 + counts := [maxBuckets]uint64{} + + return func(_ time.Time, samples []metrics.Sample) any { + hist := samples[idx_sched_pauses_stopping_other_seconds].Value.Float64Histogram() + if histfactor == 0 { + histfactor = downsampleFactor(len(hist.Buckets), maxBuckets) + } + + return downsampleCounts(hist, histfactor, counts[:]) + } + }, + layout: func(samples []metrics.Sample) Heatmap { + hist := samples[idx_sched_pauses_stopping_other_seconds].Value.Float64Histogram() + histfactor := downsampleFactor(len(hist.Buckets), maxBuckets) + buckets := downsampleBuckets(hist, histfactor) + + return Heatmap{ + Name: "stopping-pauses-other", + Tags: []tag{tagScheduler}, + Title: "Stop-the-world Stopping Latencies (Other)", + Type: "heatmap", + UpdateFreq: 5, + Colorscale: GreenShades, + Buckets: floatseq(len(buckets)), + CustomData: buckets, + Hover: HeapmapHover{ + YName: "stopping duration", + YUnit: "duration", + ZName: "pauses", + }, + Layout: HeatmapLayout{ + YAxis: HeatmapYaxis{ + Title: "stopping duration", + TickMode: "array", + TickVals: []float64{6, 13, 20, 26, 33, 39.5, 46, 53, 60, 66, 73, 79, 86}, + TickText: []float64{1e-7, 1e-6, 1e-5, 1e-4, 1e-3, 5e-3, 1e-2, 5e-2, 1e-1, 5e-1, 1, 5, 10}, + }, + }, + InfoText: `This heatmap shows the distribution of individual non-GC-related stop-the-world stopping latencies. +This is the time it takes from deciding to stop the world until all Ps are stopped. +This is a subset of the total non-GC-related stop-the-world time. During this time, some threads may be executing. +Uses /sched/pauses/stopping/other:seconds.`, + } + }, +}) diff --git a/internal/plot/plot_threads.go b/internal/plot/plot_threads.go new file mode 100644 index 00000000..adfcbc32 --- /dev/null +++ b/internal/plot/plot_threads.go @@ -0,0 +1,39 @@ +//go:build go1.26 + +package plot + +import ( + "runtime/metrics" + "time" +) + +var _ = register(description{ + metrics: []string{ + "/sched/threads/total:threads", + }, + getvalues: func() getvalues { + return func(_ time.Time, samples []metrics.Sample) any { + threads := samples[idx_sched_threads_total_threads].Value.Uint64() + + return []uint64{threads} + } + }, + layout: Scatter{ + Name: "threads", + Tags: []tag{tagScheduler}, + Title: "Threads", + Type: "scatter", + Layout: ScatterLayout{ + Yaxis: ScatterYAxis{ + Title: "bytes", + }, + }, + Subplots: []Subplot{ + { + Name: "threads", + Unitfmt: "%{y}", + }, + }, + InfoText: "Shows the current count of live threads that are owned by the Go runtime. Uses /sched/threads/total:threads", + }, +}) diff --git a/internal/plot/plot_total_pauses_gc.go b/internal/plot/plot_total_pauses_gc.go new file mode 100644 index 00000000..55f267e1 --- /dev/null +++ b/internal/plot/plot_total_pauses_gc.go @@ -0,0 +1,58 @@ +package plot + +import ( + "runtime/metrics" + "time" +) + +var _ = register(description{ + metrics: []string{ + "/sched/pauses/total/gc:seconds", + }, + getvalues: func() getvalues { + histfactor := 0 + counts := [maxBuckets]uint64{} + + return func(_ time.Time, samples []metrics.Sample) any { + hist := samples[idx_sched_pauses_total_gc_seconds].Value.Float64Histogram() + if histfactor == 0 { + histfactor = downsampleFactor(len(hist.Buckets), maxBuckets) + } + + return downsampleCounts(hist, histfactor, counts[:]) + } + }, + layout: func(samples []metrics.Sample) Heatmap { + hist := samples[idx_sched_pauses_total_gc_seconds].Value.Float64Histogram() + histfactor := downsampleFactor(len(hist.Buckets), maxBuckets) + buckets := downsampleBuckets(hist, histfactor) + + return Heatmap{ + Name: "total-pauses-gc", + Tags: []tag{tagScheduler, tagGC}, + Title: "Stop-the-world Pause Latencies (Total)", + Type: "heatmap", + UpdateFreq: 5, + Colorscale: PinkShades, + Buckets: floatseq(len(buckets)), + CustomData: buckets, + Hover: HeapmapHover{ + YName: "pause duration", + YUnit: "duration", + ZName: "pauses", + }, + Layout: HeatmapLayout{ + YAxis: HeatmapYaxis{ + Title: "pause duration", + TickMode: "array", + TickVals: []float64{6, 13, 20, 26, 33, 39.5, 46, 53, 60, 66, 73, 79, 86}, + TickText: []float64{1e-7, 1e-6, 1e-5, 1e-4, 1e-3, 5e-3, 1e-2, 5e-2, 1e-1, 5e-1, 1, 5, 10}, + }, + }, + InfoText: `This heatmap shows the distribution of individual GC-related stop-the-world pause latencies. +This is the time from deciding to stop the world until the world is started again. +Some of this time is spent getting all threads to stop (this is measured directly in /sched/pauses/stopping/gc:seconds), during which some threads may still be running. +Uses /sched/pauses/total/gc:seconds.`, + } + }, +}) diff --git a/internal/plot/plot_total_pauses_other.go b/internal/plot/plot_total_pauses_other.go new file mode 100644 index 00000000..7a6e7bc3 --- /dev/null +++ b/internal/plot/plot_total_pauses_other.go @@ -0,0 +1,58 @@ +package plot + +import ( + "runtime/metrics" + "time" +) + +var _ = register(description{ + metrics: []string{ + "/sched/pauses/total/other:seconds", + }, + getvalues: func() getvalues { + histfactor := 0 + counts := [maxBuckets]uint64{} + + return func(_ time.Time, samples []metrics.Sample) any { + hist := samples[idx_sched_pauses_total_other_seconds].Value.Float64Histogram() + if histfactor == 0 { + histfactor = downsampleFactor(len(hist.Buckets), maxBuckets) + } + + return downsampleCounts(hist, histfactor, counts[:]) + } + }, + layout: func(samples []metrics.Sample) Heatmap { + hist := samples[idx_sched_pauses_total_other_seconds].Value.Float64Histogram() + histfactor := downsampleFactor(len(hist.Buckets), maxBuckets) + buckets := downsampleBuckets(hist, histfactor) + + return Heatmap{ + Name: "total-pauses-other", + Tags: []tag{tagScheduler}, + Title: "Stop-the-world Pause Latencies (Other)", + Type: "heatmap", + UpdateFreq: 5, + Colorscale: PinkShades, + Buckets: floatseq(len(buckets)), + CustomData: buckets, + Hover: HeapmapHover{ + YName: "pause duration", + YUnit: "duration", + ZName: "pauses", + }, + Layout: HeatmapLayout{ + YAxis: HeatmapYaxis{ + Title: "pause duration", + TickMode: "array", + TickVals: []float64{6, 13, 20, 26, 33, 39.5, 46, 53, 60, 66, 73, 79, 86}, + TickText: []float64{1e-7, 1e-6, 1e-5, 1e-4, 1e-3, 5e-3, 1e-2, 5e-2, 1e-1, 5e-1, 1, 5, 10}, + }, + }, + InfoText: `This heatmap shows the distribution of individual non-GC-related stop-the-world pause latencies. +This is the time from deciding to stop the world until the world is started again. +Some of this time is spent getting all threads to stop (measured directly in /sched/pauses/stopping/other:seconds). +Uses /sched/pauses/total/other:seconds.`, + } + }, +}) diff --git a/internal/plot/plots.go b/internal/plot/plots.go deleted file mode 100644 index cd3287aa..00000000 --- a/internal/plot/plots.go +++ /dev/null @@ -1,1046 +0,0 @@ -package plot - -import ( - "math" - "runtime/metrics" - "time" -) - -type plotDesc struct { - name string - tags []string - metrics []string - layout any - - // make creates the state (support struct) for the plot. - make func(indices ...int) metricsGetter -} - -var ( - plotDescs []plotDesc - - metricDescs = metrics.All() - metricIdx map[string]int -) - -func init() { - // We need a first set of sample in order to dimension and process the - // heatmaps buckets. - samples := make([]metrics.Sample, len(metricDescs)) - metricIdx = make(map[string]int) - - for i := range samples { - samples[i].Name = metricDescs[i].Name - metricIdx[samples[i].Name] = i - } - metrics.Read(samples) - - plotDescs = []plotDesc{ - { - name: "garbage collection", - tags: []string{"gc"}, - metrics: []string{ - "/gc/gomemlimit:bytes", - "/gc/heap/live:bytes", - "/gc/heap/goal:bytes", - "/memory/classes/total:bytes", - "/memory/classes/heap/released:bytes", - }, - layout: garbageCollectionLayout, - make: makeGarbageCollection, - }, - { - name: "heap (details)", - tags: []string{"gc"}, - metrics: []string{ - "/memory/classes/heap/objects:bytes", - "/memory/classes/heap/unused:bytes", - "/memory/classes/heap/free:bytes", - "/memory/classes/heap/released:bytes", - "/memory/classes/heap/stacks:bytes", - "/gc/heap/goal:bytes", - }, - layout: heapDetailslLayout, - make: makeHeapDetails, - }, - { - name: "live-objects", - tags: []string{"gc"}, - metrics: []string{ - "/gc/heap/objects:objects", - }, - layout: liveObjectsLayout, - make: makeLiveObjects, - }, - { - name: "live-bytes", - tags: []string{"gc"}, - metrics: []string{ - "/gc/heap/allocs:bytes", - "/gc/heap/frees:bytes", - }, - layout: liveBytesLayout, - make: makeLiveBytes, - }, - { - name: "mspan-mcache", - tags: []string{"gc"}, - metrics: []string{ - "/memory/classes/metadata/mspan/inuse:bytes", - "/memory/classes/metadata/mspan/free:bytes", - "/memory/classes/metadata/mcache/inuse:bytes", - "/memory/classes/metadata/mcache/free:bytes", - }, - layout: mspanMCacheLayout, - make: makeMSpanMCache, - }, - { - name: "goroutines", - tags: []string{"scheduler"}, - metrics: []string{ - "/sched/goroutines:goroutines", - }, - layout: goroutinesLayout, - make: makeGoroutines, - }, - { - name: "size-classes", - tags: []string{"gc"}, - metrics: []string{ - "/gc/heap/allocs-by-size:bytes", - "/gc/heap/frees-by-size:bytes", - }, - layout: sizeClassesLayout(samples), - make: makeSizeClasses, - }, - { - name: "runnable-time", - tags: []string{"scheduler"}, - metrics: []string{ - "/sched/latencies:seconds", - }, - layout: runnableTimeLayout(samples), - make: makeRunnableTime, - }, - { - name: "sched-events", - tags: []string{"scheduler"}, - metrics: []string{ - "/sched/latencies:seconds", - "/sched/gomaxprocs:threads", - }, - layout: schedEventsLayout, - make: makeSchedEvents, - }, - { - name: "cgo", - tags: []string{"misc"}, - metrics: []string{ - "/cgo/go-to-c-calls:calls", - }, - layout: cgoLayout, - make: makeCGO, - }, - { - name: "gc-stack-size", - tags: []string{"gc"}, - metrics: []string{ - "/gc/stack/starting-size:bytes", - }, - layout: gcStackSizeLayout, - make: makeGCStackSize, - }, - { - name: "gc-cycles", - tags: []string{"gc"}, - metrics: []string{ - "/gc/cycles/automatic:gc-cycles", - "/gc/cycles/forced:gc-cycles", - "/gc/cycles/total:gc-cycles", - }, - layout: gcCyclesLayout, - make: makeGCCycles, - }, - { - name: "memory-classes", - tags: []string{"gc"}, - metrics: []string{ - "/memory/classes/os-stacks:bytes", - "/memory/classes/other:bytes", - "/memory/classes/profiling/buckets:bytes", - "/memory/classes/total:bytes", - }, - layout: memoryClassesLayout, - make: makeMemoryClasses, - }, - { - name: "cpu-gc", - tags: []string{"cpu", "gc"}, - metrics: []string{ - "/cpu/classes/gc/mark/assist:cpu-seconds", - "/cpu/classes/gc/mark/dedicated:cpu-seconds", - "/cpu/classes/gc/mark/idle:cpu-seconds", - "/cpu/classes/gc/pause:cpu-seconds", - }, - layout: cpuGCLayout, - make: makeCPUgc, - }, - { - name: "cpu-scavenger", - tags: []string{"cpu", "gc"}, - metrics: []string{ - "/cpu/classes/scavenge/assist:cpu-seconds", - "/cpu/classes/scavenge/background:cpu-seconds", - }, - layout: cpuScavengerLayout, - make: makeCPUscavenger, - }, - { - name: "cpu-overall", - tags: []string{"cpu"}, - metrics: []string{ - "/cpu/classes/user:cpu-seconds", - "/cpu/classes/scavenge/total:cpu-seconds", - "/cpu/classes/idle:cpu-seconds", - "/cpu/classes/gc/total:cpu-seconds", - "/cpu/classes/total:cpu-seconds", - }, - layout: cpuOverallLayout, - make: makeCPUoverall, - }, - { - name: "mutex-wait", - tags: []string{"misc"}, - metrics: []string{ - "/sync/mutex/wait/total:seconds", - }, - layout: mutexWaitLayout, - make: makeMutexWait, - }, - { - name: "gc-scan", - tags: []string{"gc"}, - metrics: []string{ - "/gc/scan/globals:bytes", - "/gc/scan/heap:bytes", - "/gc/scan/stack:bytes", - }, - layout: gcScanLayout, - make: makeGCScan, - }, - { - name: "alloc-free-rate", - tags: []string{"gc"}, - metrics: []string{ - "/gc/heap/allocs:objects", - "/gc/heap/frees:objects", - }, - layout: allocFreeRatesLayout, - make: makeAllocFreeRates, - }, - { - name: "total-pauses-gc", - tags: []string{"scheduler"}, - metrics: []string{ - "/sched/pauses/total/gc:seconds", - }, - layout: gcTotalPausesLayout(samples), - make: makeGCTotalPauses, - }, - { - name: "total-pauses-other", - tags: []string{"scheduler"}, - metrics: []string{ - "/sched/pauses/total/other:seconds", - }, - layout: otherTotalPausesLayout(samples), - make: makeOtherTotalPauses, - }, - { - name: "stopping-pauses-gc", - tags: []string{"scheduler"}, - metrics: []string{ - "/sched/pauses/stopping/gc:seconds", - }, - layout: gcStoppingPausesLayout(samples), - make: makeGCStoppingPauses, - }, - { - name: "stopping-pauses-other", - tags: []string{"scheduler"}, - metrics: []string{ - "/sched/pauses/stopping/other:seconds", - }, - layout: otherStoppingPausesLayout(samples), - make: makeGCStoppingOther, - }, - } -} - -// garbage collection -type garbageCollection struct { - idxmemlimit int - idxheaplive int - idxheapgoal int - idxmemtotal int - idxheapreleased int -} - -func makeGarbageCollection(indices ...int) metricsGetter { - return &garbageCollection{ - idxmemlimit: indices[0], - idxheaplive: indices[1], - idxheapgoal: indices[2], - idxmemtotal: indices[3], - idxheapreleased: indices[4], - } -} - -func (p *garbageCollection) values(samples []metrics.Sample) any { - memLimit := samples[p.idxmemlimit].Value.Uint64() - heapLive := samples[p.idxheaplive].Value.Uint64() - heapGoal := samples[p.idxheapgoal].Value.Uint64() - memTotal := samples[p.idxmemtotal].Value.Uint64() - heapReleased := samples[p.idxheapreleased].Value.Uint64() - - if memLimit == math.MaxInt64 { - memLimit = 0 - } - - return []uint64{ - memLimit, - memTotal - heapReleased, - heapLive, - heapGoal, - } -} - -// heap (details) - -type heapDetails struct { - idxobj int - idxunused int - idxfree int - idxreleased int - idxstacks int - idxgoal int -} - -func makeHeapDetails(indices ...int) metricsGetter { - return &heapDetails{ - idxobj: indices[0], - idxunused: indices[1], - idxfree: indices[2], - idxreleased: indices[3], - idxstacks: indices[4], - idxgoal: indices[5], - } -} - -func (p *heapDetails) values(samples []metrics.Sample) any { - heapObjects := samples[p.idxobj].Value.Uint64() - heapUnused := samples[p.idxunused].Value.Uint64() - heapFree := samples[p.idxfree].Value.Uint64() - heapReleased := samples[p.idxreleased].Value.Uint64() - heapStacks := samples[p.idxstacks].Value.Uint64() - nextGC := samples[p.idxgoal].Value.Uint64() - - heapIdle := heapReleased + heapFree - heapInUse := heapObjects + heapUnused - heapSys := heapInUse + heapIdle - - return []uint64{ - heapSys, - heapObjects, - heapStacks, - nextGC, - } -} - -// live objects - -type liveObjects struct { - idxobjects int -} - -func makeLiveObjects(indices ...int) metricsGetter { - return &liveObjects{ - idxobjects: indices[0], - } -} - -func (p *liveObjects) values(samples []metrics.Sample) any { - gcHeapObjects := samples[p.idxobjects].Value.Uint64() - return []uint64{ - gcHeapObjects, - } -} - -// live bytes - -type liveBytes struct { - idxallocs int - idxfrees int -} - -func makeLiveBytes(indices ...int) metricsGetter { - return &liveBytes{ - idxallocs: indices[0], - idxfrees: indices[1], - } -} - -func (p *liveBytes) values(samples []metrics.Sample) any { - allocBytes := samples[p.idxallocs].Value.Uint64() - freedBytes := samples[p.idxfrees].Value.Uint64() - return []uint64{ - allocBytes - freedBytes, - } -} - -// mspan mcache - -type mspanMcache struct { - enabled bool - - idxmspanInuse int - idxmspanFree int - idxmcacheInuse int - idxmcacheFree int -} - -func makeMSpanMCache(indices ...int) metricsGetter { - return &mspanMcache{ - idxmspanInuse: indices[0], - idxmspanFree: indices[1], - idxmcacheInuse: indices[2], - idxmcacheFree: indices[3], - } -} - -func (p *mspanMcache) values(samples []metrics.Sample) any { - mspanInUse := samples[p.idxmspanInuse].Value.Uint64() - mspanSys := samples[p.idxmspanFree].Value.Uint64() - mcacheInUse := samples[p.idxmcacheInuse].Value.Uint64() - mcacheSys := samples[p.idxmcacheFree].Value.Uint64() - return []uint64{ - mspanInUse, - mspanSys, - mcacheInUse, - mcacheSys, - } -} - -// goroutines - -type goroutines struct { - idxgs int -} - -func makeGoroutines(indices ...int) metricsGetter { - return &goroutines{ - idxgs: indices[0], - } -} - -func (p *goroutines) values(samples []metrics.Sample) any { - return []uint64{samples[p.idxgs].Value.Uint64()} -} - -// size classes - -type sizeClasses struct { - sizeClasses []uint64 - - idxallocs int - idxfrees int -} - -func makeSizeClasses(indices ...int) metricsGetter { - return &sizeClasses{ - idxallocs: indices[0], - idxfrees: indices[1], - } -} - -func (p *sizeClasses) values(samples []metrics.Sample) any { - allocsBySize := samples[p.idxallocs].Value.Float64Histogram() - freesBySize := samples[p.idxfrees].Value.Float64Histogram() - - if p.sizeClasses == nil { - p.sizeClasses = make([]uint64, len(allocsBySize.Counts)) - } - - for i := range p.sizeClasses { - p.sizeClasses[i] = allocsBySize.Counts[i] - freesBySize.Counts[i] - } - return p.sizeClasses -} - -// gc pauses - -type gcpauses struct { - histfactor int - counts [maxBuckets]uint64 - - idxgcpauses int -} - -func makeGCTotalPauses(indices ...int) metricsGetter { - return &gcpauses{ - idxgcpauses: indices[0], - } -} - -func (p *gcpauses) values(samples []metrics.Sample) any { - if p.histfactor == 0 { - gcpauses := samples[p.idxgcpauses].Value.Float64Histogram() - p.histfactor = downsampleFactor(len(gcpauses.Buckets), maxBuckets) - } - - gcpauses := samples[p.idxgcpauses].Value.Float64Histogram() - return downsampleCounts(gcpauses, p.histfactor, p.counts[:]) -} - -// runnable time - -type runnableTime struct { - histfactor int - counts [maxBuckets]uint64 - - idxschedlat int -} - -func makeRunnableTime(indices ...int) metricsGetter { - return &runnableTime{ - idxschedlat: indices[0], - } -} - -func (p *runnableTime) values(samples []metrics.Sample) any { - if p.histfactor == 0 { - schedlat := samples[p.idxschedlat].Value.Float64Histogram() - p.histfactor = downsampleFactor(len(schedlat.Buckets), maxBuckets) - } - - schedlat := samples[p.idxschedlat].Value.Float64Histogram() - - return downsampleCounts(schedlat, p.histfactor, p.counts[:]) -} - -// sched events - -type schedEvents struct { - idxschedlat int - idxGomaxprocs int - lasttot uint64 -} - -func makeSchedEvents(indices ...int) metricsGetter { - return &schedEvents{ - idxschedlat: indices[0], - idxGomaxprocs: indices[1], - lasttot: math.MaxUint64, - } -} - -// gTrackingPeriod is currently always 8. Guard it behind build tags when that -// changes. See https://github.com/golang/go/blob/go1.18.4/src/runtime/runtime2.go#L502-L504 -const currentGtrackingPeriod = 8 - -// TODO show scheduling events per seconds -func (p *schedEvents) values(samples []metrics.Sample) any { - schedlat := samples[p.idxschedlat].Value.Float64Histogram() - gomaxprocs := samples[p.idxGomaxprocs].Value.Uint64() - - total := uint64(0) - for _, v := range schedlat.Counts { - total += v - } - total *= currentGtrackingPeriod - - curtot := total - p.lasttot - if p.lasttot == math.MaxUint64 { - // We don't want a big spike at statsviz launch in case the process has - // been running for some time and curtot is high. - curtot = 0 - } - p.lasttot = total - - ftot := float64(curtot) - - return []float64{ - ftot, - ftot / float64(gomaxprocs), - } -} - -// cgo - -type cgo struct { - idxgo2c int - lastgo2c uint64 -} - -func makeCGO(indices ...int) metricsGetter { - return &cgo{ - idxgo2c: indices[0], - lastgo2c: math.MaxUint64, - } -} - -// TODO show cgo calls per second -func (p *cgo) values(samples []metrics.Sample) any { - go2c := samples[p.idxgo2c].Value.Uint64() - curgo2c := go2c - p.lastgo2c - if p.lastgo2c == math.MaxUint64 { - curgo2c = 0 - } - p.lastgo2c = go2c - - return []uint64{curgo2c} -} - -// gc stack size - -type gcStackSize struct { - idxstack int -} - -func makeGCStackSize(indices ...int) metricsGetter { - return &gcStackSize{ - idxstack: indices[0], - } -} - -func (p *gcStackSize) values(samples []metrics.Sample) any { - stackSize := samples[p.idxstack].Value.Uint64() - return []uint64{stackSize} -} - -// gc cycles - -type gcCycles struct { - idxAutomatic int - idxForced int - idxTotal int - - lastAuto, lastForced, lastTotal uint64 -} - -func makeGCCycles(indices ...int) metricsGetter { - return &gcCycles{ - idxAutomatic: indices[0], - idxForced: indices[1], - idxTotal: indices[2], - } -} - -func (p *gcCycles) values(samples []metrics.Sample) any { - total := samples[p.idxTotal].Value.Uint64() - auto := samples[p.idxAutomatic].Value.Uint64() - forced := samples[p.idxForced].Value.Uint64() - - if p.lastTotal == 0 { - p.lastTotal = total - p.lastForced = forced - p.lastAuto = auto - return []uint64{0, 0} - } - - ret := []uint64{ - auto - p.lastAuto, - forced - p.lastForced, - } - - p.lastForced = forced - p.lastAuto = auto - - return ret -} - -// memory classes - -type memoryClasses struct { - idxOSStacks int - idxOther int - idxProfBuckets int - idxTotal int -} - -func makeMemoryClasses(indices ...int) metricsGetter { - return &memoryClasses{ - idxOSStacks: indices[0], - idxOther: indices[1], - idxProfBuckets: indices[2], - idxTotal: indices[3], - } -} - -func (p *memoryClasses) values(samples []metrics.Sample) any { - osStacks := samples[p.idxOSStacks].Value.Uint64() - other := samples[p.idxOther].Value.Uint64() - profBuckets := samples[p.idxProfBuckets].Value.Uint64() - total := samples[p.idxTotal].Value.Uint64() - - return []uint64{ - osStacks, - other, - profBuckets, - total, - } -} - -// cpu (gc) - -type CPUgc struct { - idxMarkAssist int - idxMarkDedicated int - idxMarkIdle int - idxPause int - idxTotal int - - lastTime time.Time - - lastMarkAssist float64 - lastMarkDedicated float64 - lastMarkIdle float64 - lastPause float64 -} - -func makeCPUgc(indices ...int) metricsGetter { - return &CPUgc{ - idxMarkAssist: indices[0], - idxMarkDedicated: indices[1], - idxMarkIdle: indices[2], - idxPause: indices[3], - } -} - -func (p *CPUgc) values(samples []metrics.Sample) any { - curMarkAssist := samples[p.idxMarkAssist].Value.Float64() - curMarkDedicated := samples[p.idxMarkDedicated].Value.Float64() - curMarkIdle := samples[p.idxMarkIdle].Value.Float64() - curPause := samples[p.idxPause].Value.Float64() - - if p.lastTime.IsZero() { - p.lastMarkAssist = curMarkAssist - p.lastMarkDedicated = curMarkDedicated - p.lastMarkIdle = curMarkIdle - p.lastPause = curPause - p.lastTime = time.Now() - - return []float64{0, 0, 0, 0, 0} - } - - t := time.Since(p.lastTime).Seconds() - - markAssist := (curMarkAssist - p.lastMarkAssist) / t - markDedicated := (curMarkDedicated - p.lastMarkDedicated) / t - markIdle := (curMarkIdle - p.lastMarkIdle) / t - pause := (curPause - p.lastPause) / t - - p.lastMarkAssist = curMarkAssist - p.lastMarkDedicated = curMarkDedicated - p.lastMarkIdle = curMarkIdle - p.lastPause = curPause - p.lastTime = time.Now() - - return []float64{ - markAssist, - markDedicated, - markIdle, - pause, - } -} - -// cpu (scavenger) - -type cpuScavenger struct { - idxScavengeAssist int - idxScavengeBackground int - - lastTime time.Time - - lastScavengeAssist float64 - lastScavengeBackground float64 -} - -func makeCPUscavenger(indices ...int) metricsGetter { - return &cpuScavenger{ - idxScavengeAssist: indices[0], - idxScavengeBackground: indices[1], - } -} - -func (p *cpuScavenger) values(samples []metrics.Sample) any { - curScavengeAssist := samples[p.idxScavengeAssist].Value.Float64() - curScavengeBackground := samples[p.idxScavengeBackground].Value.Float64() - - if p.lastTime.IsZero() { - p.lastScavengeAssist = curScavengeAssist - p.lastScavengeBackground = curScavengeBackground - p.lastTime = time.Now() - - return []float64{0, 0, 0, 0, 0} - } - - t := time.Since(p.lastTime).Seconds() - - scavengeAssist := (curScavengeAssist - p.lastScavengeAssist) / t - scavengeBackground := (curScavengeBackground - p.lastScavengeBackground) / t - - p.lastScavengeAssist = curScavengeAssist - p.lastScavengeBackground = curScavengeBackground - - return []float64{ - scavengeAssist, - scavengeBackground, - } -} - -// cpu overall - -type cpuOverall struct { - idxUser int - idxScavenge int - idxIdle int - idxGCtotal int - idxTotal int - - lastTime time.Time - lastUser float64 - lastScavenge float64 - lastIdle float64 - lastGCtotal float64 - lastTotal float64 -} - -func makeCPUoverall(indices ...int) metricsGetter { - return &cpuOverall{ - idxUser: indices[0], - idxScavenge: indices[1], - idxIdle: indices[2], - idxGCtotal: indices[3], - idxTotal: indices[4], - } -} - -func (p *cpuOverall) values(samples []metrics.Sample) any { - curUser := samples[p.idxUser].Value.Float64() - curScavenge := samples[p.idxScavenge].Value.Float64() - curIdle := samples[p.idxIdle].Value.Float64() - curGCtotal := samples[p.idxGCtotal].Value.Float64() - curTotal := samples[p.idxTotal].Value.Float64() - - if p.lastTime.IsZero() { - p.lastUser = curUser - p.lastScavenge = curScavenge - p.lastIdle = curIdle - p.lastGCtotal = curGCtotal - p.lastTotal = curTotal - - p.lastTime = time.Now() - return []float64{0, 0, 0, 0, 0} - } - - t := time.Since(p.lastTime).Seconds() - - user := (curUser - p.lastUser) / t - scavenge := (curScavenge - p.lastScavenge) / t - idle := (curIdle - p.lastIdle) / t - gcTotal := (curGCtotal - p.lastGCtotal) / t - total := (curTotal - p.lastTotal) / t - - p.lastUser = curUser - p.lastScavenge = curScavenge - p.lastIdle = curIdle - p.lastGCtotal = curGCtotal - p.lastTotal = curTotal - - return []float64{ - user, - scavenge, - idle, - gcTotal, - total, - } -} - -// mutex wait - -type mutexWait struct { - idxMutexWait int - - lastTime time.Time - lastMutexWait float64 -} - -func makeMutexWait(indices ...int) metricsGetter { - return &mutexWait{ - idxMutexWait: indices[0], - } -} - -func (p *mutexWait) values(samples []metrics.Sample) any { - if p.lastTime.IsZero() { - p.lastTime = time.Now() - p.lastMutexWait = samples[p.idxMutexWait].Value.Float64() - - return []float64{0} - } - - t := time.Since(p.lastTime).Seconds() - - mutexWait := (samples[p.idxMutexWait].Value.Float64() - p.lastMutexWait) / t - - p.lastMutexWait = samples[p.idxMutexWait].Value.Float64() - p.lastTime = time.Now() - - return []float64{ - mutexWait, - } -} - -// gc scan - -type gcScan struct { - idxGlobals int - idxHeap int - idxStack int -} - -func makeGCScan(indices ...int) metricsGetter { - return &gcScan{ - idxGlobals: indices[0], - idxHeap: indices[1], - idxStack: indices[2], - } -} - -func (p *gcScan) values(samples []metrics.Sample) any { - globals := samples[p.idxGlobals].Value.Uint64() - heap := samples[p.idxHeap].Value.Uint64() - stack := samples[p.idxStack].Value.Uint64() - return []uint64{ - globals, - heap, - stack, - } -} - -// alloc/free rates -type allocFreeRates struct { - idxallocs int - idxfrees int - - lasttime time.Time - lastallocs uint64 - lastfrees uint64 -} - -func makeAllocFreeRates(indices ...int) metricsGetter { - return &allocFreeRates{ - idxallocs: indices[0], - idxfrees: indices[1], - } -} - -func (p *allocFreeRates) values(samples []metrics.Sample) any { - if p.lasttime.IsZero() { - p.lasttime = time.Now() - p.lastallocs = samples[p.idxallocs].Value.Uint64() - p.lastfrees = samples[p.idxfrees].Value.Uint64() - - return []float64{0, 0} - } - - t := time.Since(p.lasttime).Seconds() - - allocs := float64(samples[p.idxallocs].Value.Uint64()-p.lastallocs) / t - frees := float64(samples[p.idxfrees].Value.Uint64()-p.lastfrees) / t - - p.lastallocs = samples[p.idxallocs].Value.Uint64() - p.lastfrees = samples[p.idxfrees].Value.Uint64() - p.lasttime = time.Now() - - return []float64{ - allocs, - frees, - } -} - -// stopping pauses (GC) - -type stoppingPausesGC struct { - histfactor int - counts [maxBuckets]uint64 - - idxstoppinggc int -} - -func makeGCStoppingPauses(indices ...int) metricsGetter { - return &stoppingPausesGC{ - idxstoppinggc: indices[0], - } -} - -func (p *stoppingPausesGC) values(samples []metrics.Sample) any { - if p.histfactor == 0 { - stoppinggc := samples[p.idxstoppinggc].Value.Float64Histogram() - p.histfactor = downsampleFactor(len(stoppinggc.Buckets), maxBuckets) - } - - stoppinggc := samples[p.idxstoppinggc].Value.Float64Histogram() - return downsampleCounts(stoppinggc, p.histfactor, p.counts[:]) -} - -// stopping pauses (Other) - -type stoppingPausesOther struct { - histfactor int - counts [maxBuckets]uint64 - - idxstoppingother int -} - -func makeGCStoppingOther(indices ...int) metricsGetter { - return &stoppingPausesOther{ - idxstoppingother: indices[0], - } -} - -func (p *stoppingPausesOther) values(samples []metrics.Sample) any { - if p.histfactor == 0 { - stoppingother := samples[p.idxstoppingother].Value.Float64Histogram() - p.histfactor = downsampleFactor(len(stoppingother.Buckets), maxBuckets) - } - - stoppingother := samples[p.idxstoppingother].Value.Float64Histogram() - return downsampleCounts(stoppingother, p.histfactor, p.counts[:]) -} - -// total pauses (Other) - -type totalPausesOther struct { - histfactor int - counts [maxBuckets]uint64 - - idxtotalother int -} - -func makeOtherTotalPauses(indices ...int) metricsGetter { - return &totalPausesOther{ - idxtotalother: indices[0], - } -} - -func (p *totalPausesOther) values(samples []metrics.Sample) any { - if p.histfactor == 0 { - totalother := samples[p.idxtotalother].Value.Float64Histogram() - p.histfactor = downsampleFactor(len(totalother.Buckets), maxBuckets) - } - - totalother := samples[p.idxtotalother].Value.Float64Histogram() - return downsampleCounts(totalother, p.histfactor, p.counts[:]) -} diff --git a/internal/plot/plots_test.go b/internal/plot/plots_test.go index 091c1615..4c20ca52 100644 --- a/internal/plot/plots_test.go +++ b/internal/plot/plots_test.go @@ -4,11 +4,45 @@ import ( "fmt" "os" "runtime/metrics" + "slices" "strings" "testing" "text/tabwriter" ) +func TestUnusedRuntimeMetrics(t *testing.T) { + // This test just prints the metrics we're not using in any plot. It can't + // fail, it's informational. + used := make(map[string]bool) + for _, d := range reg().descriptions { + for _, m := range d.metrics { + used[m] = true + } + } + + // Discard godebug metrics and used metrics. + all := metrics.All() + all = slices.DeleteFunc(all, func(desc metrics.Description) bool { + return strings.HasPrefix(desc.Name, "/godebug/") + }) + all = slices.DeleteFunc(all, func(desc metrics.Description) bool { + return used[desc.Name] + }) + + if len(all) == 0 { + t.Log("all metrics are used!") + return + } + + t.Log("some runtime metrics are not used by any plot:\n") + + w := tabwriter.NewWriter(os.Stderr, 0, 8, 2, ' ', 0) + for _, m := range all { + fmt.Fprintf(w, "\t%s\t%s\t%s\n", m.Name, kindstr(m.Kind), clampstr(m.Description)) + } + w.Flush() +} + func kindstr(k metrics.ValueKind) string { switch k { case metrics.KindUint64: @@ -22,23 +56,10 @@ func kindstr(k metrics.ValueKind) string { } } -func TestUnusedRuntimeMetrics(t *testing.T) { - // Creating a config compiles the list of metrics used by Statsviz. - l, err := NewList(nil) - if err != nil { - t.Fatal(err) - } - _ = l.Config() - - // This "test" can't fail. It just prints which of the metrics exported by - // runtime/metrics are not used in any Statsviz plot. - w := tabwriter.NewWriter(os.Stdout, 0, 0, 1, ' ', 0) - for _, m := range metrics.All() { - if _, ok := l.usedMetrics[m.Name]; !ok && - !strings.HasPrefix(m.Name, "/godebug/") && - m.Name != "/gc/pauses:seconds" /* deprecated name */ { - fmt.Fprintf(w, "%s\t%s\n", m.Name, kindstr(m.Kind)) - } +func clampstr(s string) string { + const maxlen = 80 + if len(s) > maxlen { + return s[:maxlen-3] + "..." } - w.Flush() + return s } diff --git a/internal/plot/registry.go b/internal/plot/registry.go index 7b5afa00..29ba1a61 100644 --- a/internal/plot/registry.go +++ b/internal/plot/registry.go @@ -1,203 +1,98 @@ +// Package plot defines and builds the plots available in Statsviz. package plot import ( - "encoding/json" - "fmt" - "io" - "runtime/debug" "runtime/metrics" "slices" "sync" - "time" ) -// IsReservedPlotName reports whether that name is reserved for Statsviz plots -// and thus can't be chosen by user (for user plots). -func IsReservedPlotName(name string) bool { - if name == "timestamp" || name == "lastgc" { - return true - } - return slices.ContainsFunc(plotDescs, func(pd plotDesc) bool { - return pd.name == name - }) -} - -// a metricsGetter extracts, from a sample of runtime metrics, a slice with all -// the metrics necessary for a single plot. -type metricsGetter interface { - values([]metrics.Sample) any // []uint64 | []float64 -} - -// List holds all the plots that statsviz knows about. Some plots might be -// disabled, if they rely on metrics that are unknown to the current Go version. -type List struct { - rtPlots []runtimePlot - userPlots []UserPlot +type tag = string - once sync.Once // ensure Config is built once - cfg *Config +const ( + tagGC tag = "gc" + tagScheduler tag = "scheduler" + tagCPU tag = "cpu" + tagMisc tag = "misc" +) - idxs map[string]int // map metrics name to idx in samples and descs - descs []metrics.Description - usedMetrics map[string]struct{} +type description struct { + metrics []string + layout any - mu sync.Mutex // protects samples in case of concurrent calls to WriteValues - samples []metrics.Sample + // getvalues creates the state (support struct) for the plot. + getvalues func() getvalues } -type runtimePlot struct { - name string - rt metricsGetter - layout any // Scatter | Heatmap -} +type registry struct { + allMetrics map[string]bool // names of all known runtime/metrics metrics + metrics []string + descriptions []description -func NewList(userPlots []UserPlot) (*List, error) { - if name := hasDuplicatePlotNames(userPlots); name != "" { - return nil, fmt.Errorf("duplicate plot name %s", name) - } + samples []metrics.Sample // lazily built, only with the metrics we need +} - descs := metrics.All() - pl := &List{ - idxs: make(map[string]int), - descs: descs, - samples: make([]metrics.Sample, len(descs)), - userPlots: userPlots, - usedMetrics: make(map[string]struct{}), +var reg = sync.OnceValue(func() *registry { + reg := ®istry{ + allMetrics: make(map[string]bool), } - for i := range pl.samples { - pl.samples[i].Name = pl.descs[i].Name + for _, m := range metrics.All() { + reg.allMetrics[m.Name] = true } - metrics.Read(pl.samples) - return pl, nil -} + return reg +}) -func (pl *List) enabledPlots() []runtimePlot { - plots := make([]runtimePlot, 0, len(plotDescs)) - - for _, plot := range plotDescs { - indices, enabled := pl.indicesFor(plot.metrics...) - if enabled { - plots = append(plots, runtimePlot{ - name: plot.name, - rt: plot.make(indices...), - layout: complete(plot.layout, plot.name, plot.tags), - }) - } +func (r *registry) mustidx(metric string) int { + if !r.allMetrics[metric] { + panic(metric + ": unknown metric in " + goversion()) } - return plots -} - -// complete the layout with names and tags. -func complete(layout any, name string, tags []string) any { - switch layout := layout.(type) { - case Scatter: - layout.Name = name - layout.Tags = tags - return layout - case Heatmap: - layout.Name = name - layout.Tags = tags - return layout - default: - panic(fmt.Sprintf("unknown plot layout type %T", layout)) + idx := slices.Index(r.metrics, metric) + if idx == -1 { + r.metrics = append(r.metrics, metric) + idx = len(r.metrics) - 1 } -} -func (pl *List) Config() *Config { - pl.once.Do(func() { - pl.rtPlots = pl.enabledPlots() - - layouts := make([]any, len(pl.rtPlots)) - for i := range pl.rtPlots { - layouts[i] = pl.rtPlots[i].layout - } - - pl.cfg = &Config{ - Events: []string{"lastgc"}, - Series: layouts, - } - - // User plots go at the back. - for i := range pl.userPlots { - pl.cfg.Series = append(pl.cfg.Series, pl.userPlots[i].Layout()) - } - }) - return pl.cfg + return idx } -// WriteValues writes into w a JSON object containing the data points for all -// plots at the current instant. -func (pl *List) WriteValues(w io.Writer) error { - pl.mu.Lock() - defer pl.mu.Unlock() - - metrics.Read(pl.samples) - - // lastgc time series is used as source to represent garbage collection - // timestamps as vertical bars on certain plots. - gcStats := debug.GCStats{} - debug.ReadGCStats(&gcStats) - - m := map[string]any{ - // Javascript timestamps are in millis. - "lastgc": []int64{gcStats.LastGC.UnixMilli()}, +func (r *registry) buildSamples() { + r.samples = make([]metrics.Sample, len(r.metrics)) + for i := range r.samples { + r.samples[i].Name = r.metrics[i] } +} - for _, p := range pl.rtPlots { - m[p.name] = p.rt.values(pl.samples) +func (r *registry) read() []metrics.Sample { + if r.samples == nil { + r.buildSamples() } + metrics.Read(r.samples) - for i := range pl.userPlots { - up := &pl.userPlots[i] - switch { - case up.Scatter != nil: - vals := make([]float64, len(up.Scatter.Funcs)) - for i := range up.Scatter.Funcs { - vals[i] = up.Scatter.Funcs[i]() - } - m[up.Scatter.Plot.Name] = vals - case up.Heatmap != nil: - panic("unimplemented") - } - } + return r.samples +} - type data struct { - Series map[string]any `json:"series"` - Timestamp int64 `json:"timestamp"` +func (r *registry) register(desc description) { + // Histograms need special handling. + type heatmapLayoutFunc = func(samples []metrics.Sample) Heatmap + if buildLayout, ok := desc.layout.(heatmapLayoutFunc); ok { + // Rebuild samples to include the required metrics. + r.buildSamples() + samples := r.read() + desc.layout = buildLayout(samples) } - if err := json.NewEncoder(w).Encode(struct { - Event string `json:"event"` - Data data `json:"data"` - }{ - Event: "metrics", - Data: data{ - Series: m, - Timestamp: time.Now().UnixMilli(), - }, - }); err != nil { - return fmt.Errorf("failed to write/convert metrics values to json: %v", err) - } - return nil + r.descriptions = append(r.descriptions, desc) } -// indicesFor retrieves indices for the specified metrics, and a boolean -// indicating whether they were all found. -func (pl *List) indicesFor(metricNames ...string) ([]int, bool) { - indices := make([]int, len(metricNames)) - allFound := true - - for i, name := range metricNames { - pl.usedMetrics[name] = struct{}{} // record the metrics we use - - idx, ok := metricIdx[name] - if !ok { - allFound = false - } - indices[i] = idx - } +func mustidx(metric string) int { + // TODO: adapter for refactoring: remove + return reg().mustidx(metric) +} - return indices, allFound +func register(desc description) struct{} { + // TODO: adapter for refactoring: remove + reg().register(desc) + return struct{}{} } diff --git a/netconn_test.go b/netconn_test.go new file mode 100644 index 00000000..cbe28d89 --- /dev/null +++ b/netconn_test.go @@ -0,0 +1,442 @@ +//go:build go1.25 +// +build go1.25 + +// Copyright 2024 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package statsviz + +import ( + "bytes" + "context" + "io" + "math" + "net" + "net/netip" + "os" + "sync" + "testing/synctest" + "time" +) + +func fakeNetListen() *fakeNetListener { + li := &fakeNetListener{ + setc: make(chan struct{}, 1), + unsetc: make(chan struct{}, 1), + addr: netip.MustParseAddrPort("127.0.0.1:8000"), + locPort: 10000, + } + li.unsetc <- struct{}{} + return li +} + +type fakeNetListener struct { + setc, unsetc chan struct{} + queue []net.Conn + closed bool + addr netip.AddrPort + locPort uint16 + + onDial func() // called when making a new connection + onClose func(*fakeNetConn) // called when closing a connection + + trackConns bool // set this to record all created conns + conns []*fakeNetConn +} + +func (li *fakeNetListener) lock() { + select { + case <-li.setc: + case <-li.unsetc: + } +} + +func (li *fakeNetListener) unlock() { + if li.closed || len(li.queue) > 0 { + li.setc <- struct{}{} + } else { + li.unsetc <- struct{}{} + } +} + +func (li *fakeNetListener) connect() *fakeNetConn { + if li.onDial != nil { + li.onDial() + } + li.lock() + defer li.unlock() + locAddr := netip.AddrPortFrom(netip.AddrFrom4([4]byte{127, 0, 0, 1}), li.locPort) + li.locPort++ + c0, c1 := fakeNetPipe(li.addr, locAddr) + c0.onClose = li.onClose + c1.onClose = li.onClose + li.queue = append(li.queue, c0) + if li.trackConns { + li.conns = append(li.conns, c0) + } + return c1 +} + +func (li *fakeNetListener) Accept() (net.Conn, error) { + <-li.setc + defer li.unlock() + if li.closed { + return nil, net.ErrClosed + } + c := li.queue[0] + li.queue = li.queue[1:] + return c, nil +} + +func (li *fakeNetListener) Close() error { + li.lock() + defer li.unlock() + li.closed = true + return nil +} + +func (li *fakeNetListener) Addr() net.Addr { + return net.TCPAddrFromAddrPort(li.addr) +} + +// fakeNetPipe creates an in-memory, full duplex network connection. +// +// Unlike net.Pipe, the connection is not synchronous. +// Writes are made to a buffer, and return immediately. +// By default, the buffer size is unlimited. +func fakeNetPipe(s1ap, s2ap netip.AddrPort) (r, w *fakeNetConn) { + s1addr := net.TCPAddrFromAddrPort(s1ap) + s2addr := net.TCPAddrFromAddrPort(s2ap) + s1 := newSynctestNetConnHalf(s1addr) + s2 := newSynctestNetConnHalf(s2addr) + c1 := &fakeNetConn{loc: s1, rem: s2} + c2 := &fakeNetConn{loc: s2, rem: s1} + c1.peer = c2 + c2.peer = c1 + return c1, c2 +} + +// A fakeNetConn is one endpoint of the connection created by fakeNetPipe. +type fakeNetConn struct { + // local and remote connection halves. + // Each half contains a buffer. + // Reads pull from the local buffer, and writes push to the remote buffer. + loc, rem *fakeNetConnHalf + + // When set, synctest.Wait is automatically called before reads and after writes. + autoWait bool + + // peer is the other endpoint. + peer *fakeNetConn + + onClose func(*fakeNetConn) // called when closing +} + +// Read reads data from the connection. +func (c *fakeNetConn) Read(b []byte) (n int, err error) { + if c.autoWait { + synctest.Wait() + } + return c.loc.read(b) +} + +// Peek returns the available unread read buffer, +// without consuming its contents. +func (c *fakeNetConn) Peek() []byte { + if c.autoWait { + synctest.Wait() + } + return c.loc.peek() +} + +// Write writes data to the connection. +func (c *fakeNetConn) Write(b []byte) (n int, err error) { + if c.autoWait { + defer synctest.Wait() + } + return c.rem.write(b) +} + +// IsClosed reports whether the peer has closed its end of the connection. +func (c *fakeNetConn) IsClosedByPeer() bool { + if c.autoWait { + synctest.Wait() + } + c.rem.lock() + defer c.rem.unlock() + // If the remote half of the conn is returning ErrClosed, + // the peer has closed the connection. + return c.rem.readErr == net.ErrClosed +} + +// Close closes the connection. +func (c *fakeNetConn) Close() error { + if c.onClose != nil { + c.onClose(c) + } + // Local half of the conn is now closed. + c.loc.lock() + c.loc.writeErr = net.ErrClosed + c.loc.readErr = net.ErrClosed + c.loc.buf.Reset() + c.loc.unlock() + // Remote half of the connection reads EOF after reading any remaining data. + c.rem.lock() + if c.rem.readErr != nil { + c.rem.readErr = io.EOF + } + c.rem.unlock() + if c.autoWait { + synctest.Wait() + } + return nil +} + +// LocalAddr returns the (fake) local network address. +func (c *fakeNetConn) LocalAddr() net.Addr { + return c.loc.addr +} + +// LocalAddr returns the (fake) remote network address. +func (c *fakeNetConn) RemoteAddr() net.Addr { + return c.rem.addr +} + +// SetDeadline sets the read and write deadlines for the connection. +func (c *fakeNetConn) SetDeadline(t time.Time) error { + c.SetReadDeadline(t) + c.SetWriteDeadline(t) + return nil +} + +// SetReadDeadline sets the read deadline for the connection. +func (c *fakeNetConn) SetReadDeadline(t time.Time) error { + c.loc.rctx.setDeadline(t) + return nil +} + +// SetWriteDeadline sets the write deadline for the connection. +func (c *fakeNetConn) SetWriteDeadline(t time.Time) error { + c.rem.wctx.setDeadline(t) + return nil +} + +// SetReadBufferSize sets the read buffer limit for the connection. +// Writes by the peer will block so long as the buffer is full. +func (c *fakeNetConn) SetReadBufferSize(size int) { + c.loc.setReadBufferSize(size) +} + +// fakeNetConnHalf is one data flow in the connection created by fakeNetPipe. +// Each half contains a buffer. Writes to the half push to the buffer, and reads pull from it. +type fakeNetConnHalf struct { + addr net.Addr + + // Read and write timeouts. + rctx, wctx deadlineContext + + // A half can be readable and/or writable. + // + // These four channels act as a lock, + // and allow waiting for readability/writability. + // When the half is unlocked, exactly one channel contains a value. + // When the half is locked, all channels are empty. + lockr chan struct{} // readable + lockw chan struct{} // writable + lockrw chan struct{} // readable and writable + lockc chan struct{} // neither readable nor writable + + bufMax int // maximum buffer size + buf bytes.Buffer + readErr error // error returned by reads + writeErr error // error returned by writes +} + +func newSynctestNetConnHalf(addr net.Addr) *fakeNetConnHalf { + h := &fakeNetConnHalf{ + addr: addr, + lockw: make(chan struct{}, 1), + lockr: make(chan struct{}, 1), + lockrw: make(chan struct{}, 1), + lockc: make(chan struct{}, 1), + bufMax: math.MaxInt, // unlimited + } + h.unlock() + return h +} + +// lock locks h. +func (h *fakeNetConnHalf) lock() { + select { + case <-h.lockw: // writable + case <-h.lockr: // readable + case <-h.lockrw: // readable and writable + case <-h.lockc: // neither readable nor writable + } +} + +// h unlocks h. +func (h *fakeNetConnHalf) unlock() { + canRead := h.readErr != nil || h.buf.Len() > 0 + canWrite := h.writeErr != nil || h.bufMax > h.buf.Len() + switch { + case canRead && canWrite: + h.lockrw <- struct{}{} // readable and writable + case canRead: + h.lockr <- struct{}{} // readable + case canWrite: + h.lockw <- struct{}{} // writable + default: + h.lockc <- struct{}{} // neither readable nor writable + } +} + +// waitAndLockForRead waits until h is readable and locks it. +func (h *fakeNetConnHalf) waitAndLockForRead() error { + // First a non-blocking select to see if we can make immediate progress. + // This permits using a canceled context for a non-blocking operation. + select { + case <-h.lockr: + return nil // readable + case <-h.lockrw: + return nil // readable and writable + default: + } + ctx := h.rctx.context() + select { + case <-h.lockr: + return nil // readable + case <-h.lockrw: + return nil // readable and writable + case <-ctx.Done(): + return context.Cause(ctx) + } +} + +// waitAndLockForWrite waits until h is writable and locks it. +func (h *fakeNetConnHalf) waitAndLockForWrite() error { + // First a non-blocking select to see if we can make immediate progress. + // This permits using a canceled context for a non-blocking operation. + select { + case <-h.lockw: + return nil // writable + case <-h.lockrw: + return nil // readable and writable + default: + } + ctx := h.wctx.context() + select { + case <-h.lockw: + return nil // writable + case <-h.lockrw: + return nil // readable and writable + case <-ctx.Done(): + return context.Cause(ctx) + } +} + +func (h *fakeNetConnHalf) peek() []byte { + h.lock() + defer h.unlock() + return h.buf.Bytes() +} + +func (h *fakeNetConnHalf) read(b []byte) (n int, err error) { + if err := h.waitAndLockForRead(); err != nil { + return 0, err + } + defer h.unlock() + if h.buf.Len() == 0 && h.readErr != nil { + return 0, h.readErr + } + return h.buf.Read(b) +} + +func (h *fakeNetConnHalf) setReadBufferSize(size int) { + h.lock() + defer h.unlock() + h.bufMax = size +} + +func (h *fakeNetConnHalf) write(b []byte) (n int, err error) { + for n < len(b) { + nn, err := h.writePartial(b[n:]) + n += nn + if err != nil { + return n, err + } + } + return n, nil +} + +func (h *fakeNetConnHalf) writePartial(b []byte) (n int, err error) { + if err := h.waitAndLockForWrite(); err != nil { + return 0, err + } + defer h.unlock() + if h.writeErr != nil { + return 0, h.writeErr + } + writeMax := h.bufMax - h.buf.Len() + if writeMax < len(b) { + b = b[:writeMax] + } + return h.buf.Write(b) +} + +// deadlineContext converts a changable deadline (as in net.Conn.SetDeadline) into a Context. +type deadlineContext struct { + mu sync.Mutex + ctx context.Context + cancel context.CancelCauseFunc + timer *time.Timer +} + +// context returns a Context which expires when the deadline does. +func (t *deadlineContext) context() context.Context { + t.mu.Lock() + defer t.mu.Unlock() + if t.ctx == nil { + t.ctx, t.cancel = context.WithCancelCause(context.Background()) + } + return t.ctx +} + +// setDeadline sets the current deadline. +func (t *deadlineContext) setDeadline(deadline time.Time) { + t.mu.Lock() + defer t.mu.Unlock() + // If t.ctx is non-nil and t.cancel is nil, then t.ctx was canceled + // and we should create a new one. + if t.ctx == nil || t.cancel == nil { + t.ctx, t.cancel = context.WithCancelCause(context.Background()) + } + // Stop any existing deadline from expiring. + if t.timer != nil { + t.timer.Stop() + } + if deadline.IsZero() { + // No deadline. + return + } + now := time.Now() + if !deadline.After(now) { + // Deadline has already expired. + t.cancel(os.ErrDeadlineExceeded) + t.cancel = nil + return + } + if t.timer != nil { + // Reuse existing deadline timer. + t.timer.Reset(deadline.Sub(now)) + return + } + // Create a new timer to cancel the context at the deadline. + t.timer = time.AfterFunc(deadline.Sub(now), func() { + t.mu.Lock() + defer t.mu.Unlock() + t.cancel(os.ErrDeadlineExceeded) + t.cancel = nil + }) +} diff --git a/statsviz.go b/statsviz.go index 62c7ecbd..77718c26 100644 --- a/statsviz.go +++ b/statsviz.go @@ -41,6 +41,8 @@ package statsviz import ( + "bytes" + "context" "fmt" "net/http" "os" @@ -68,6 +70,8 @@ func RegisterDefault(opts ...Option) error { } // Register registers the Statsviz HTTP handlers on the provided mux. +// +// Register must be called once per application. func Register(mux *http.ServeMux, opts ...Option) error { srv, err := NewServer(opts...) if err != nil { @@ -85,7 +89,13 @@ func Register(mux *http.ServeMux, opts ...Option) error { // browser to receive metrics updates from the server. // // The zero value is a valid Server, with default options. +// +// NOTE: Having more than one Server in the same program is not supported (and +// is not useful anyway). type Server struct { + cancel context.CancelFunc // terminate goroutines + clients *clients // connected websocket clients + interval time.Duration // interval between consecutive metrics emission root string // HTTP path root plots *plot.List // plots shown on the user interface @@ -124,18 +134,52 @@ func (s *Server) init(opts ...Option) error { } s.plots = pl + ctx, cancel := context.WithCancel(context.Background()) + s.cancel = cancel + s.clients = newClients(ctx, s.plots.Config()) + + // Collect metrics. + go func() { + tick := time.NewTicker(s.interval) + defer tick.Stop() + defer cancel() + + for { + select { + case <-ctx.Done(): + return + case <-tick.C: + buf := bytes.Buffer{} + if _, err := s.plots.WriteTo(&buf); err != nil { + dbglog("failed to collect metrics: %v", err) + return + } + s.clients.broadcast(buf.Bytes()) + } + } + }() + return nil } // Register registers the Statsviz HTTP handlers on the provided mux. +// +// Register must be called once per application. func (s *Server) Register(mux *http.ServeMux) { if s.plots == nil { s.init() } + mux.Handle(s.root+"/", s.Index()) mux.HandleFunc(s.root+"/ws", s.Ws()) } +// Close releases all resources used by the Server. +func (s *Server) Close() error { + s.cancel() + return nil +} + // Option is a configuration option for the Server. type Option func(*Server) error @@ -155,7 +199,7 @@ func SendFrequency(intv time.Duration) Option { // The default is "/debug/statsviz". func Root(path string) Option { return func(s *Server) error { - s.root = path + s.root = strings.TrimSuffix(path, "/") return nil } } @@ -173,7 +217,7 @@ func TimeseriesPlot(tsp TimeSeriesPlot) Option { // interface HTML page. By default, the handler is served at the path specified // by the root. Use [WithRoot] to change the path. func (s *Server) Index() http.HandlerFunc { - prefix := strings.TrimSuffix(s.root, "/") + "/" + prefix := s.root + "/" dist := http.FileServerFS(static.Assets()) return http.StripPrefix(prefix, dist).ServeHTTP } @@ -191,6 +235,12 @@ func parseBoolEnv(name string) bool { var debug = false +func dbglog(format string, args ...any) { + if debug { + fmt.Fprintf(os.Stderr, "statsviz: "+format+"\n", args...) + } +} + var wsUpgrader = sync.OnceValue(func() websocket.Upgrader { var checkOrigin func(r *http.Request) bool @@ -215,63 +265,10 @@ func (s *Server) Ws() http.HandlerFunc { upgrader := wsUpgrader() ws, err := upgrader.Upgrade(w, r, nil) if err != nil { - if debug { - fmt.Fprintf(os.Stderr, "statsviz: failed to upgrade connection: %v\n", err) - } + dbglog("failed to upgrade connection: %v", err) return } - defer ws.Close() - - // Ignore websocket errors here. They mainly happen when the other end - // of the connection closes. We can't handle them in any meaningful way - // anyways, and since we're a library we don't want to spam the program - // output streams. If really necessary, we could export a io.Writer the - // user could connect to whatever logging facility they use, that seems - // overkill for now. - - if err := s.sendConfig(ws); err != nil { - if debug { - fmt.Fprintf(os.Stderr, "statsviz: failed to send config: %v\n", err) - } - } - if err := s.sendStats(ws, s.interval); err != nil { - if debug { - fmt.Fprintf(os.Stderr, "statsviz: failed to send stats: %v\n", err) - } - } - } -} - -type wsmsg struct { - Event string `json:"event"` - Data any `json:"data"` -} - -func (s *Server) sendConfig(conn *websocket.Conn) error { - return conn.WriteJSON(wsmsg{ - Event: "config", - Data: s.plots.Config(), - }) -} - -// sendStats sends runtime statistics over the WebSocket connection. -func (s *Server) sendStats(conn *websocket.Conn, frequency time.Duration) error { - tick := time.NewTicker(frequency) - defer tick.Stop() - - for range tick.C { - w, err := conn.NextWriter(websocket.TextMessage) - if err != nil { - return err - } - if err := s.plots.WriteValues(w); err != nil { - return err - } - if err := w.Close(); err != nil { - return err - } + s.clients.add(ws) } - - panic("unreachable") } diff --git a/statsviz_test.go b/statsviz_test.go index e3f8ea0d..2ad98ad9 100644 --- a/statsviz_test.go +++ b/statsviz_test.go @@ -103,14 +103,15 @@ func testWs(t *testing.T, f http.Handler, URL string) { } // Check the content of 2 consecutive payloads. - for i := 0; i < 2; i++ { - // Verifies that we've received 1 time series (goroutines) and one - // heatmap (sizeClasses). + for range 2 { + // Verifies that we've received: + // - 1 time series (cgo) + // - 1 heatmap (sizeClasses). var msg struct { Event string `json:"event"` Data struct { Series struct { - Goroutines []uint64 `json:"goroutines"` + CGo []uint64 `json:"cgo"` SizeClasses []uint64 `json:"size-classes"` } `json:"series"` } `json:"data"` @@ -121,8 +122,8 @@ func testWs(t *testing.T, f http.Handler, URL string) { } // The time series must have one and only one element - if len(msg.Data.Series.Goroutines) != 1 { - t.Errorf("len(goroutines) = %d, want 1", len(msg.Data.Series.Goroutines)) + if len(msg.Data.Series.CGo) != 1 { + t.Errorf("len(cgo) = %d, want 1", len(msg.Data.Series.CGo)) } // Heatmaps should have many elements, check that there's more than one. if len(msg.Data.Series.SizeClasses) <= 1 { @@ -131,12 +132,6 @@ func testWs(t *testing.T, f http.Handler, URL string) { } } -func TestWs(t *testing.T) { - t.Parallel() - - testWs(t, newServer(t).Ws(), "http://example.com/debug/statsviz/ws") -} - func TestWsCantUpgrade(t *testing.T) { url := "http://example.com/debug/statsviz/ws" @@ -156,10 +151,20 @@ func testRegister(t *testing.T, f http.Handler, baseURL string) { } func TestRegister(t *testing.T) { + t.Run("defaultmux", func(t *testing.T) { + t.Parallel() + + mux := http.DefaultServeMux + + Register(mux) + testRegister(t, mux, "http://example.com/debug/statsviz/") + }) + t.Run("default", func(t *testing.T) { t.Parallel() mux := http.NewServeMux() + newServer(t).Register(mux) testRegister(t, mux, "http://example.com/debug/statsviz/") }) @@ -178,10 +183,19 @@ func TestRegister(t *testing.T) { t.Parallel() mux := http.NewServeMux() - newServer(t, - Root(""), - ).Register(mux) + srv := newServer(t, Root("")) + srv.Register(mux) + testRegister(t, mux, "http://example.com/") + }) + + t.Run("slash", func(t *testing.T) { + t.Parallel() + + mux := http.NewServeMux() + + srv := newServer(t, Root("/")) + srv.Register(mux) testRegister(t, mux, "http://example.com/") }) @@ -189,10 +203,9 @@ func TestRegister(t *testing.T) { t.Parallel() mux := http.NewServeMux() - newServer(t, - Root("/path/to/statsviz"), - ).Register(mux) + srv := newServer(t, Root("/path/to/statsviz")) + srv.Register(mux) testRegister(t, mux, "http://example.com/path/to/statsviz/") }) @@ -200,28 +213,24 @@ func TestRegister(t *testing.T) { t.Parallel() mux := http.NewServeMux() - newServer(t, + + srv := newServer(t, Root("/path/to/statsviz"), SendFrequency(100*time.Millisecond), - ).Register(mux) - + ) + srv.Register(mux) testRegister(t, mux, "http://example.com/path/to/statsviz/") }) t.Run("non-positive frequency", func(t *testing.T) { t.Parallel() - if _, err := NewServer( + _, err := NewServer( Root("/path/to/statsviz"), SendFrequency(-1), - ); err == nil { + ) + if err == nil { t.Errorf("NewServer() should have errored") } }) } - -func TestRegisterDefault(t *testing.T) { - mux := http.DefaultServeMux - Register(mux) - testRegister(t, mux, "http://example.com/debug/statsviz/") -} diff --git a/sync_test.go b/sync_test.go new file mode 100644 index 00000000..f152c4c8 --- /dev/null +++ b/sync_test.go @@ -0,0 +1,91 @@ +//go:build go1.25 +// +build go1.25 + +package statsviz + +import ( + "context" + "net" + "net/http" + "net/http/httptest" + "net/url" + "testing" + "testing/synctest" + + "github.com/gorilla/websocket" +) + +func TestWsConcurrent(t *testing.T) { + t.Parallel() + + synctest.Test(t, func(t *testing.T) { + srv := newServer(t) + srv.Register(http.NewServeMux()) + + li := fakeNetListen() + s := &httptest.Server{ + Listener: li, + Config: &http.Server{Handler: srv.Ws()}, + } + s.Start() + + // Build a "ws://" url using the httptest server URL. + u, err := url.Parse(s.URL) + if err != nil { + t.Fatal(err) + } + u.Scheme = "ws" + + const numConns = 10 + const numMessages = 200 + + errCh := make(chan error, numConns) + + synctestWsDialer := websocket.Dialer{ + NetDialContext: func(ctx context.Context, network string, addr string) (net.Conn, error) { + c := li.connect() + return c, nil + }, + } + + for i := range numConns { + go func(connID int) { + ws, _, err := synctestWsDialer.Dial(u.String(), nil) + if err != nil { + errCh <- err + return + } + defer ws.Close() + + // First message is the plots configuration + var cfg map[string]any + if err := ws.ReadJSON(&cfg); err != nil { + errCh <- err + return + } + + // Read multiple data messages to ensure state is being accessed + for range numMessages { + var msg map[string]any + if err := ws.ReadJSON(&msg); err != nil { + errCh <- err + return + } + } + + errCh <- nil + }(i) + } + + for i := range numConns { + if err := <-errCh; err != nil { + t.Fatalf("connection %d failed: %v", i, err) + } + } + + srv.Close() + s.Close() + + synctest.Wait() + }) +}