summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-05-08 19:43:33 +0300
committerPaul Buetow <paul@buetow.org>2026-05-08 19:43:33 +0300
commitf86699a94bdde7d973ba5d6fa3e7ca4ab2f234fb (patch)
treec2e11bfa4fdac965623a8058716c514fce507eba /internal
parentc41a38ef55bb80681a6cc0b2161f8e84bfabcf17 (diff)
add duration metric, tolerate missing tracepoints, ship el8 build
- Bubbles, treemap, icicle, and the live flamegraph 'b' cycle now include syscall duration (sum) as a third metric alongside events and bytes. Statsengine snapshots expose TotalLatencyNs to support this. - AttachAll takes an optional warn callback. Production passes one so older kernels that lack newer tracepoints log a warning and keep going instead of aborting startup. - Dockerfile.el8 + scripts/build-with-docker-el8.sh + mage buildDockerEl8 produce ior.el8, a static binary built against Rocky Linux 8 glibc for RHEL/Rocky/Alma 8 hosts. - README.md documents installing mage and the new el8 target.
Diffstat (limited to 'internal')
-rw-r--r--internal/ior_bpfsetup.go10
-rw-r--r--internal/probemanager/manager.go17
-rw-r--r--internal/probemanager/manager_test.go59
-rw-r--r--internal/statsengine/filerank.go13
-rw-r--r--internal/statsengine/process.go13
-rw-r--r--internal/statsengine/snapshot.go21
-rw-r--r--internal/statsengine/syscall.go25
-rw-r--r--internal/tui/dashboard/bubbles.go114
-rw-r--r--internal/tui/dashboard/files.go14
-rw-r--r--internal/tui/dashboard/files_test.go6
-rw-r--r--internal/tui/dashboard/icicle.go20
-rw-r--r--internal/tui/dashboard/model.go17
-rw-r--r--internal/tui/dashboard/treemap.go71
-rw-r--r--internal/tui/flamegraph/controls.go14
-rw-r--r--internal/tui/flamegraph/model_test.go12
15 files changed, 285 insertions, 141 deletions
diff --git a/internal/ior_bpfsetup.go b/internal/ior_bpfsetup.go
index 3500106..9ab9186 100644
--- a/internal/ior_bpfsetup.go
+++ b/internal/ior_bpfsetup.go
@@ -3,6 +3,7 @@ package internal
import (
"context"
"fmt"
+ "os"
appconfig "ior/internal/config"
"ior/internal/flags"
@@ -65,7 +66,14 @@ func setupBPFModule(parentCtx context.Context, cfg flags.Config) (*bpf.Module, *
}
mgr := probemanager.NewManager(libbpfTracepointModule{module: bpfModule})
- if err := mgr.AttachAll(cfg.ShouldIAttachTracepoint, tracepoints.List); err != nil {
+ // Per-syscall attach failures are non-fatal: on older kernels the
+ // tracepoint may be absent (e.g. binary built against a newer kernel).
+ // We log and skip; the affected probe stays in the manager with its
+ // lastErr set, so States() and the TUI surface the failure.
+ warn := func(syscall string, err error) {
+ fmt.Fprintf(os.Stderr, "ior: skipping tracepoint for %s: %v\n", syscall, err)
+ }
+ if err := mgr.AttachAll(cfg.ShouldIAttachTracepoint, tracepoints.List, warn); err != nil {
mgr.Close()
bpfModule.Close()
return nil, nil, releaseBindings, setupBPFModuleError("attach probes", err)
diff --git a/internal/probemanager/manager.go b/internal/probemanager/manager.go
index 288af41..677762b 100644
--- a/internal/probemanager/manager.go
+++ b/internal/probemanager/manager.go
@@ -79,7 +79,17 @@ func (m *Manager) Register(syscall string, pair TracepointPair) {
}
// AttachAll registers and attaches all tracepoint pairs selected by shouldAttach.
-func (m *Manager) AttachAll(shouldAttach func(string) bool, tpNames []string) error {
+//
+// If onAttachError is non-nil, per-syscall attach failures are reported through
+// the callback and AttachAll continues with the remaining tracepoints. This is
+// the desired mode in production: when running a binary built on a newer kernel
+// against an older one, some syscalls' tracepoints may be absent and the
+// corresponding attach call returns ENOENT. The error is recorded on the
+// probe entry (visible via States()) regardless of the callback.
+//
+// If onAttachError is nil, AttachAll preserves the strict legacy behavior and
+// returns the first attach error to the caller. Tests rely on this mode.
+func (m *Manager) AttachAll(shouldAttach func(string) bool, tpNames []string, onAttachError func(syscall string, err error)) error {
if m == nil {
return errors.New("probe manager is nil")
}
@@ -94,7 +104,10 @@ func (m *Manager) AttachAll(shouldAttach func(string) bool, tpNames []string) er
continue
}
if err := m.Attach(syscall); err != nil {
- return err
+ if onAttachError == nil {
+ return err
+ }
+ onAttachError(syscall, err)
}
}
return nil
diff --git a/internal/probemanager/manager_test.go b/internal/probemanager/manager_test.go
index dc0c474..2beb11e 100644
--- a/internal/probemanager/manager_test.go
+++ b/internal/probemanager/manager_test.go
@@ -97,7 +97,7 @@ func TestManagerAttachAllToggleAndCounts(t *testing.T) {
err := mgr.AttachAll(func(tp string) bool { return tp == "sys_enter_read" || tp == "sys_exit_read" }, []string{
"sys_enter_read", "sys_exit_read", "sys_enter_write", "sys_exit_write",
- })
+ }, nil)
if err != nil {
t.Fatalf("AttachAll returned error: %v", err)
}
@@ -210,7 +210,7 @@ func TestManagerAttachWaitsForDetachBeforeReturning(t *testing.T) {
errs: map[string]error{},
}
mgr := NewManager(attacher)
- if err := mgr.AttachAll(nil, []string{"sys_enter_close", "sys_exit_close"}); err != nil {
+ if err := mgr.AttachAll(nil, []string{"sys_enter_close", "sys_exit_close"}, nil); err != nil {
t.Fatalf("AttachAll returned error: %v", err)
}
@@ -281,7 +281,7 @@ func TestManagerCloseWaitsForDetachAndDoesNotDoubleDestroy(t *testing.T) {
errs: map[string]error{},
}
mgr := NewManager(attacher)
- if err := mgr.AttachAll(nil, []string{"sys_enter_close", "sys_exit_close"}); err != nil {
+ if err := mgr.AttachAll(nil, []string{"sys_enter_close", "sys_exit_close"}, nil); err != nil {
t.Fatalf("AttachAll returned error: %v", err)
}
@@ -337,7 +337,7 @@ func TestManagerDetachDestroysLinks(t *testing.T) {
errs: map[string]error{},
}
mgr := NewManager(attacher)
- if err := mgr.AttachAll(nil, []string{"sys_enter_close", "sys_exit_close"}); err != nil {
+ if err := mgr.AttachAll(nil, []string{"sys_enter_close", "sys_exit_close"}, nil); err != nil {
t.Fatalf("AttachAll returned error: %v", err)
}
if err := mgr.Detach("close"); err != nil {
@@ -359,7 +359,7 @@ func TestManagerDetachFailureKeepsActiveStateForUndetachedLink(t *testing.T) {
errs: map[string]error{},
}
mgr := NewManager(attacher)
- if err := mgr.AttachAll(nil, []string{"sys_enter_close", "sys_exit_close"}); err != nil {
+ if err := mgr.AttachAll(nil, []string{"sys_enter_close", "sys_exit_close"}, nil); err != nil {
t.Fatalf("AttachAll returned error: %v", err)
}
@@ -388,7 +388,7 @@ func TestManagerClosePreventsFurtherOperations(t *testing.T) {
errs: map[string]error{},
}
mgr := NewManager(attacher)
- if err := mgr.AttachAll(nil, []string{"sys_enter_open", "sys_exit_open"}); err != nil {
+ if err := mgr.AttachAll(nil, []string{"sys_enter_open", "sys_exit_open"}, nil); err != nil {
t.Fatalf("AttachAll returned error: %v", err)
}
if err := mgr.Close(); err != nil {
@@ -407,7 +407,7 @@ func TestManagerAttachAllReturnsProgramError(t *testing.T) {
},
}
mgr := NewManager(attacher)
- err := mgr.AttachAll(nil, []string{"sys_enter_read", "sys_exit_read"})
+ err := mgr.AttachAll(nil, []string{"sys_enter_read", "sys_exit_read"}, nil)
if err == nil {
t.Fatalf("expected attach error")
}
@@ -417,6 +417,45 @@ func TestManagerAttachAllReturnsProgramError(t *testing.T) {
}
}
+// When onAttachError is supplied, AttachAll should report each per-syscall
+// failure through the callback and continue attaching the remaining probes.
+// This is the path that lets a binary built on a newer kernel run on an older
+// one where some tracepoints don't exist.
+func TestManagerAttachAllWarnAndContinue(t *testing.T) {
+ attacher := &fakeAttacher{
+ programs: map[string]*fakeProgram{
+ "handle_sys_enter_write": {},
+ "handle_sys_exit_write": {},
+ },
+ errs: map[string]error{
+ "handle_sys_enter_read": errors.New("no such tracepoint"),
+ },
+ }
+ mgr := NewManager(attacher)
+
+ var warned []string
+ warn := func(syscall string, err error) {
+ warned = append(warned, syscall+":"+err.Error())
+ }
+ err := mgr.AttachAll(nil, []string{
+ "sys_enter_read", "sys_exit_read",
+ "sys_enter_write", "sys_exit_write",
+ }, warn)
+ if err != nil {
+ t.Fatalf("AttachAll returned error despite warn callback: %v", err)
+ }
+ if len(warned) != 1 {
+ t.Fatalf("expected exactly 1 warning, got %d (%v)", len(warned), warned)
+ }
+ if !strings.Contains(warned[0], "read") || !strings.Contains(warned[0], "no such tracepoint") {
+ t.Fatalf("unexpected warning text: %q", warned[0])
+ }
+ active, total := mgr.ActiveCount()
+ if active != 1 || total != 2 {
+ t.Fatalf("expected write attached and read skipped, got active=%d total=%d", active, total)
+ }
+}
+
func TestManagerAttachAllPicksUpNewTracepointsOnLaterCall(t *testing.T) {
attacher := &fakeAttacher{
programs: map[string]*fakeProgram{
@@ -429,7 +468,7 @@ func TestManagerAttachAllPicksUpNewTracepointsOnLaterCall(t *testing.T) {
}
mgr := NewManager(attacher)
- if err := mgr.AttachAll(nil, []string{"sys_enter_read", "sys_exit_read"}); err != nil {
+ if err := mgr.AttachAll(nil, []string{"sys_enter_read", "sys_exit_read"}, nil); err != nil {
t.Fatalf("AttachAll(read) returned error: %v", err)
}
states := mgr.States()
@@ -437,7 +476,7 @@ func TestManagerAttachAllPicksUpNewTracepointsOnLaterCall(t *testing.T) {
t.Fatalf("expected only read after first call, got %+v", states)
}
- if err := mgr.AttachAll(nil, []string{"sys_enter_read", "sys_exit_read", "sys_enter_write", "sys_exit_write"}); err != nil {
+ if err := mgr.AttachAll(nil, []string{"sys_enter_read", "sys_exit_read", "sys_enter_write", "sys_exit_write"}, nil); err != nil {
t.Fatalf("AttachAll(read+write) returned error: %v", err)
}
states = mgr.States()
@@ -458,7 +497,7 @@ func TestManagerIsActiveReflectsCurrentState(t *testing.T) {
errs: map[string]error{},
}
mgr := NewManager(attacher)
- if err := mgr.AttachAll(nil, []string{"sys_enter_read", "sys_exit_read"}); err != nil {
+ if err := mgr.AttachAll(nil, []string{"sys_enter_read", "sys_exit_read"}, nil); err != nil {
t.Fatalf("AttachAll returned error: %v", err)
}
if !mgr.IsActive("read") {
diff --git a/internal/statsengine/filerank.go b/internal/statsengine/filerank.go
index d24ab93..ef43c6e 100644
--- a/internal/statsengine/filerank.go
+++ b/internal/statsengine/filerank.go
@@ -191,12 +191,13 @@ func (s fileSnapshotInput) toSnapshot() FileSnapshot {
}
return FileSnapshot{
- Path: s.path,
- Accesses: s.accesses,
- BytesRead: s.bytesRead,
- BytesWritten: s.bytesWritten,
- AvgLatencyNs: avg,
- MaxLatencyNs: s.maxLatency,
+ Path: s.path,
+ Accesses: s.accesses,
+ BytesRead: s.bytesRead,
+ BytesWritten: s.bytesWritten,
+ AvgLatencyNs: avg,
+ MaxLatencyNs: s.maxLatency,
+ TotalLatencyNs: s.totalLatency,
}
}
diff --git a/internal/statsengine/process.go b/internal/statsengine/process.go
index 3bfd019..b7eb6e7 100644
--- a/internal/statsengine/process.go
+++ b/internal/statsengine/process.go
@@ -174,11 +174,12 @@ func (s processSnapshotInput) toSnapshot(rateDiv float64) ProcessSnapshot {
}
return ProcessSnapshot{
- PID: s.pid,
- Comm: s.comm,
- Syscalls: s.count,
- RatePerSec: safeRate(s.count, rateDiv),
- Bytes: s.totalBytes,
- AvgLatencyNs: avg,
+ PID: s.pid,
+ Comm: s.comm,
+ Syscalls: s.count,
+ RatePerSec: safeRate(s.count, rateDiv),
+ Bytes: s.totalBytes,
+ AvgLatencyNs: avg,
+ TotalLatencyNs: s.totalLatency,
}
}
diff --git a/internal/statsengine/snapshot.go b/internal/statsengine/snapshot.go
index f2b617b..7a95ab8 100644
--- a/internal/statsengine/snapshot.go
+++ b/internal/statsengine/snapshot.go
@@ -68,12 +68,13 @@ type SyscallSnapshot struct {
Errors uint64
Bytes uint64
- LatencyMinNs uint64
- LatencyMaxNs uint64
- LatencyMeanNs float64
- LatencyP50Ns uint64
- LatencyP95Ns uint64
- LatencyP99Ns uint64
+ LatencyMinNs uint64
+ LatencyMaxNs uint64
+ LatencyMeanNs float64
+ TotalLatencyNs uint64
+ LatencyP50Ns uint64
+ LatencyP95Ns uint64
+ LatencyP99Ns uint64
}
// FileSnapshot is an aggregated per-file ranking entry.
@@ -84,8 +85,9 @@ type FileSnapshot struct {
BytesRead uint64
BytesWritten uint64
- AvgLatencyNs float64
- MaxLatencyNs uint64
+ AvgLatencyNs float64
+ MaxLatencyNs uint64
+ TotalLatencyNs uint64
}
// ProcessSnapshot is an aggregated per-process entry.
@@ -97,7 +99,8 @@ type ProcessSnapshot struct {
RatePerSec float64
Bytes uint64
- AvgLatencyNs float64
+ AvgLatencyNs float64
+ TotalLatencyNs uint64
}
// HistogramBucketSnapshot is one bucket of a histogram snapshot.
diff --git a/internal/statsengine/syscall.go b/internal/statsengine/syscall.go
index 93931d1..2ef929a 100644
--- a/internal/statsengine/syscall.go
+++ b/internal/statsengine/syscall.go
@@ -195,18 +195,19 @@ func (s *syscallStats) ensurePercentiles() {
func (s syscallSnapshotInput) toSnapshot(rateDiv float64) SyscallSnapshot {
return SyscallSnapshot{
- TraceID: s.traceID,
- Name: s.name,
- Count: s.count,
- RatePerSec: safeRate(s.count, rateDiv),
- Errors: s.errorCount,
- Bytes: s.totalBytes,
- LatencyMinNs: s.minLatency,
- LatencyMaxNs: s.maxLatency,
- LatencyMeanNs: float64(s.totalLatency) / float64(maxU64(s.count, 1)),
- LatencyP50Ns: s.p50Latency,
- LatencyP95Ns: s.p95Latency,
- LatencyP99Ns: s.p99Latency,
+ TraceID: s.traceID,
+ Name: s.name,
+ Count: s.count,
+ RatePerSec: safeRate(s.count, rateDiv),
+ Errors: s.errorCount,
+ Bytes: s.totalBytes,
+ LatencyMinNs: s.minLatency,
+ LatencyMaxNs: s.maxLatency,
+ LatencyMeanNs: float64(s.totalLatency) / float64(maxU64(s.count, 1)),
+ TotalLatencyNs: s.totalLatency,
+ LatencyP50Ns: s.p50Latency,
+ LatencyP95Ns: s.p95Latency,
+ LatencyP99Ns: s.p99Latency,
}
}
diff --git a/internal/tui/dashboard/bubbles.go b/internal/tui/dashboard/bubbles.go
index f50eba8..f4fa6d5 100644
--- a/internal/tui/dashboard/bubbles.go
+++ b/internal/tui/dashboard/bubbles.go
@@ -19,8 +19,9 @@ import (
type bubbleMetric string
const (
- bubbleMetricCount bubbleMetric = "count"
- bubbleMetricBytes bubbleMetric = "bytes"
+ bubbleMetricCount bubbleMetric = "count"
+ bubbleMetricBytes bubbleMetric = "bytes"
+ bubbleMetricDuration bubbleMetric = "duration"
)
const (
@@ -32,20 +33,22 @@ const (
)
type bubbleDatum struct {
- ID string
- Label string
- Count uint64
- Bytes uint64
- Detail string
+ ID string
+ Label string
+ Count uint64
+ Bytes uint64
+ Duration uint64
+ Detail string
}
type bubbleNode struct {
- ID string
- Label string
- Detail string
- Count uint64
- Bytes uint64
- Value uint64
+ ID string
+ Label string
+ Detail string
+ Count uint64
+ Bytes uint64
+ Duration uint64
+ Value uint64
radiusSpring harmonica.Spring
xSpring harmonica.Spring
@@ -114,28 +117,35 @@ func (c *bubbleChart) SetViewport(width, height int) {
data := make([]bubbleDatum, 0, len(c.nodes))
for _, node := range c.nodes {
data = append(data, bubbleDatum{
- ID: node.ID,
- Label: node.Label,
- Count: node.Count,
- Bytes: node.Bytes,
- Detail: node.Detail,
+ ID: node.ID,
+ Label: node.Label,
+ Count: node.Count,
+ Bytes: node.Bytes,
+ Duration: node.Duration,
+ Detail: node.Detail,
})
}
c.SetData(data)
}
func (c *bubbleChart) SetMetric(metric bubbleMetric) {
- if metric != bubbleMetricBytes {
- metric = bubbleMetricCount
+ switch metric {
+ case bubbleMetricBytes, bubbleMetricDuration:
+ c.metric = metric
+ default:
+ c.metric = bubbleMetricCount
}
- c.metric = metric
}
func (c *bubbleChart) Metric() bubbleMetric {
- if c.metric == bubbleMetricBytes {
+ switch c.metric {
+ case bubbleMetricBytes:
return bubbleMetricBytes
+ case bubbleMetricDuration:
+ return bubbleMetricDuration
+ default:
+ return bubbleMetricCount
}
- return bubbleMetricCount
}
func (c *bubbleChart) SetStatusHint(hint string) {
@@ -543,17 +553,25 @@ func (c *bubbleChart) statusLine(width int) string {
}
func (c *bubbleChart) metricLabel() string {
- if c.Metric() == bubbleMetricBytes {
+ switch c.Metric() {
+ case bubbleMetricBytes:
return "bytes"
+ case bubbleMetricDuration:
+ return "duration"
+ default:
+ return "events"
}
- return "events"
}
func (c *bubbleChart) formatMetricValue(node bubbleNode) string {
- if c.Metric() == bubbleMetricBytes {
+ switch c.Metric() {
+ case bubbleMetricBytes:
return formatBytes(float64(node.Bytes))
+ case bubbleMetricDuration:
+ return formatDurationUintNs(node.Duration)
+ default:
+ return fmt.Sprintf("%d", node.Count)
}
- return fmt.Sprintf("%d", node.Count)
}
func (c *bubbleChart) palette() []color.Color {
@@ -687,6 +705,7 @@ func buildBubbleTargets(data []bubbleDatum, metric bubbleMetric, width, height i
Detail: datum.Detail,
Count: datum.Count,
Bytes: datum.Bytes,
+ Duration: datum.Duration,
Value: value,
targetRadius: targetRadius,
targetX: targetX,
@@ -767,10 +786,14 @@ func clampFloat(value, minValue, maxValue float64) float64 {
}
func bubbleValue(d bubbleDatum, metric bubbleMetric) uint64 {
- if metric == bubbleMetricBytes {
+ switch metric {
+ case bubbleMetricBytes:
return d.Bytes
+ case bubbleMetricDuration:
+ return d.Duration
+ default:
+ return d.Count
}
- return d.Count
}
func syscallBubbleData(snap *statsengine.Snapshot) []bubbleDatum {
@@ -782,11 +805,12 @@ func syscallBubbleData(snap *statsengine.Snapshot) []bubbleDatum {
for _, syscall := range rows {
detail := fmt.Sprintf("rate %.1f/s, errors %d, p95 %s", syscall.RatePerSec, syscall.Errors, formatDurationUintNs(syscall.LatencyP95Ns))
data = append(data, bubbleDatum{
- ID: syscall.Name,
- Label: syscall.Name,
- Count: syscall.Count,
- Bytes: syscall.Bytes,
- Detail: detail,
+ ID: syscall.Name,
+ Label: syscall.Name,
+ Count: syscall.Count,
+ Bytes: syscall.Bytes,
+ Duration: syscall.TotalLatencyNs,
+ Detail: detail,
})
}
return data
@@ -802,11 +826,12 @@ func filesDirBubbleData(snap *statsengine.Snapshot) []bubbleDatum {
totalBytes := dir.BytesRead + dir.BytesWritten
detail := fmt.Sprintf("dir %s, files %d, read %s, write %s", dir.Dir, dir.FileCount, formatBytes(float64(dir.BytesRead)), formatBytes(float64(dir.BytesWritten)))
data = append(data, bubbleDatum{
- ID: dir.Dir,
- Label: rootPathLabelFromFSPath(dir.Dir),
- Count: dir.Accesses,
- Bytes: totalBytes,
- Detail: detail,
+ ID: dir.Dir,
+ Label: rootPathLabelFromFSPath(dir.Dir),
+ Count: dir.Accesses,
+ Bytes: totalBytes,
+ Duration: dir.TotalLatencyNs,
+ Detail: detail,
})
}
return data
@@ -825,11 +850,12 @@ func processBubbleData(snap *statsengine.Snapshot) []bubbleDatum {
}
detail := fmt.Sprintf("pid %d, rate %.1f/s, avg %s", proc.PID, proc.RatePerSec, formatDurationNs(proc.AvgLatencyNs))
data = append(data, bubbleDatum{
- ID: fmt.Sprintf("%d/%s", proc.PID, proc.Comm),
- Label: label,
- Count: proc.Syscalls,
- Bytes: proc.Bytes,
- Detail: detail,
+ ID: fmt.Sprintf("%d/%s", proc.PID, proc.Comm),
+ Label: label,
+ Count: proc.Syscalls,
+ Bytes: proc.Bytes,
+ Duration: proc.TotalLatencyNs,
+ Detail: detail,
})
}
return data
diff --git a/internal/tui/dashboard/files.go b/internal/tui/dashboard/files.go
index df850ab..3b85a73 100644
--- a/internal/tui/dashboard/files.go
+++ b/internal/tui/dashboard/files.go
@@ -17,9 +17,10 @@ type DirSnapshot struct {
BytesRead uint64
BytesWritten uint64
- AvgLatencyNs float64
- MaxLatencyNs uint64
- FileCount uint64
+ AvgLatencyNs float64
+ MaxLatencyNs uint64
+ TotalLatencyNs uint64
+ FileCount uint64
}
type fileSortKey uint8
@@ -401,7 +402,6 @@ func aggregateFilesByDir(files []statsengine.FileSnapshot) []DirSnapshot {
}
dirs := make(map[string]DirSnapshot, len(files))
- weightedLatency := make(map[string]float64, len(files))
for _, f := range files {
dir := filepath.Dir(f.Path)
s := dirs[dir]
@@ -413,14 +413,14 @@ func aggregateFilesByDir(files []statsengine.FileSnapshot) []DirSnapshot {
s.MaxLatencyNs = f.MaxLatencyNs
}
s.FileCount++
- weightedLatency[dir] += f.AvgLatencyNs * float64(f.Accesses)
+ s.TotalLatencyNs += f.TotalLatencyNs
dirs[dir] = s
}
out := make([]DirSnapshot, 0, len(dirs))
- for dir, s := range dirs {
+ for _, s := range dirs {
if s.Accesses > 0 {
- s.AvgLatencyNs = weightedLatency[dir] / float64(s.Accesses)
+ s.AvgLatencyNs = float64(s.TotalLatencyNs) / float64(s.Accesses)
}
out = append(out, s)
}
diff --git a/internal/tui/dashboard/files_test.go b/internal/tui/dashboard/files_test.go
index 480c25f..848aa33 100644
--- a/internal/tui/dashboard/files_test.go
+++ b/internal/tui/dashboard/files_test.go
@@ -65,9 +65,9 @@ func TestFilePathWidthExpandsOnWideTerminal(t *testing.T) {
func TestAggregateFilesByDir(t *testing.T) {
files := []statsengine.FileSnapshot{
- {Path: "/var/log/a.log", Accesses: 10, BytesRead: 100, BytesWritten: 40, AvgLatencyNs: 100, MaxLatencyNs: 300},
- {Path: "/var/log/b.log", Accesses: 20, BytesRead: 200, BytesWritten: 60, AvgLatencyNs: 200, MaxLatencyNs: 500},
- {Path: "/tmp/c.log", Accesses: 5, BytesRead: 50, BytesWritten: 10, AvgLatencyNs: 1000, MaxLatencyNs: 1200},
+ {Path: "/var/log/a.log", Accesses: 10, BytesRead: 100, BytesWritten: 40, AvgLatencyNs: 100, MaxLatencyNs: 300, TotalLatencyNs: 1000},
+ {Path: "/var/log/b.log", Accesses: 20, BytesRead: 200, BytesWritten: 60, AvgLatencyNs: 200, MaxLatencyNs: 500, TotalLatencyNs: 4000},
+ {Path: "/tmp/c.log", Accesses: 5, BytesRead: 50, BytesWritten: 10, AvgLatencyNs: 1000, MaxLatencyNs: 1200, TotalLatencyNs: 5000},
}
got := aggregateFilesByDir(files)
diff --git a/internal/tui/dashboard/icicle.go b/internal/tui/dashboard/icicle.go
index 768783b..560bb2a 100644
--- a/internal/tui/dashboard/icicle.go
+++ b/internal/tui/dashboard/icicle.go
@@ -16,6 +16,7 @@ type icicleNode struct {
fullPath string
accesses uint64
bytes uint64
+ duration uint64
children map[string]*icicleNode
}
@@ -123,6 +124,7 @@ func buildIcicleTree(dirs []DirSnapshot) *icicleNode {
metricBytes := dir.BytesRead + dir.BytesWritten
current.accesses += dir.Accesses
current.bytes += metricBytes
+ current.duration += dir.TotalLatencyNs
currentPath := "/"
for _, segment := range segments {
if segment == "" {
@@ -144,6 +146,7 @@ func buildIcicleTree(dirs []DirSnapshot) *icicleNode {
}
child.accesses += dir.Accesses
child.bytes += metricBytes
+ child.duration += dir.TotalLatencyNs
current = child
}
}
@@ -297,9 +300,14 @@ func icicleStatusLine(tiles []icicleTile, selected int, metric bubbleMetric) str
selected = clampOffset(selected, len(tiles))
tile := tiles[selected]
metricValue := icicleValue(tile.node, metric)
- metricText := fmt.Sprintf("%d", metricValue)
- if metric == bubbleMetricBytes {
+ var metricText string
+ switch metric {
+ case bubbleMetricBytes:
metricText = formatBytes(float64(metricValue))
+ case bubbleMetricDuration:
+ metricText = formatDurationUintNs(metricValue)
+ default:
+ metricText = fmt.Sprintf("%d", metricValue)
}
return fmt.Sprintf(
"sel:%d/%d %s | %s=%s | accesses=%d | bytes=%s",
@@ -317,8 +325,12 @@ func icicleValue(node *icicleNode, metric bubbleMetric) uint64 {
if node == nil {
return 0
}
- if metric == bubbleMetricBytes {
+ switch metric {
+ case bubbleMetricBytes:
return node.bytes
+ case bubbleMetricDuration:
+ return node.duration
+ default:
+ return node.accesses
}
- return node.accesses
}
diff --git a/internal/tui/dashboard/model.go b/internal/tui/dashboard/model.go
index 850a483..42a9ad4 100644
--- a/internal/tui/dashboard/model.go
+++ b/internal/tui/dashboard/model.go
@@ -713,10 +713,14 @@ func sortedProcessSnapshots(rows []statsengine.ProcessSnapshot, metric bubbleMet
}
func processMetricValue(proc statsengine.ProcessSnapshot, metric bubbleMetric) uint64 {
- if metric == bubbleMetricBytes {
+ switch metric {
+ case bubbleMetricBytes:
return proc.Bytes
+ case bubbleMetricDuration:
+ return proc.TotalLatencyNs
+ default:
+ return proc.Syscalls
}
- return proc.Syscalls
}
func processSelectionLabel(proc statsengine.ProcessSnapshot) string {
@@ -1289,10 +1293,15 @@ func nextVizMode(current tabVizMode, allowed []tabVizMode) tabVizMode {
}
func nextBubbleMetric(metric bubbleMetric) bubbleMetric {
- if metric == bubbleMetricBytes {
+ // 3-way cycle: count (events) → bytes → duration → count.
+ switch metric {
+ case bubbleMetricCount:
+ return bubbleMetricBytes
+ case bubbleMetricBytes:
+ return bubbleMetricDuration
+ default:
return bubbleMetricCount
}
- return bubbleMetricBytes
}
func tickCmd(d time.Duration) tea.Cmd {
diff --git a/internal/tui/dashboard/treemap.go b/internal/tui/dashboard/treemap.go
index dd62d13..03c2917 100644
--- a/internal/tui/dashboard/treemap.go
+++ b/internal/tui/dashboard/treemap.go
@@ -17,13 +17,14 @@ import (
const maxSyscallTreemapItems = 20
type syscallTreemapItem struct {
- Name string
- Count uint64
- Bytes uint64
- Errors uint64
- P95Ns uint64
- Detail string
- Value uint64
+ Name string
+ Count uint64
+ Bytes uint64
+ Duration uint64
+ Errors uint64
+ P95Ns uint64
+ Detail string
+ Value uint64
}
type syscallTreemapTile struct {
@@ -111,11 +112,12 @@ func buildSyscallTreemapItems(snap *statsengine.Snapshot, metric bubbleMetric) [
items := make([]syscallTreemapItem, 0, len(syscalls))
for _, syscall := range syscalls {
item := syscallTreemapItem{
- Name: syscall.Name,
- Count: syscall.Count,
- Bytes: syscall.Bytes,
- Errors: syscall.Errors,
- P95Ns: syscall.LatencyP95Ns,
+ Name: syscall.Name,
+ Count: syscall.Count,
+ Bytes: syscall.Bytes,
+ Duration: syscall.TotalLatencyNs,
+ Errors: syscall.Errors,
+ P95Ns: syscall.LatencyP95Ns,
Detail: fmt.Sprintf(
"rate %.1f/s, errors %d, p95 %s",
syscall.RatePerSec,
@@ -154,9 +156,10 @@ func buildFilesTreemapItems(snap *statsengine.Snapshot, metric bubbleMetric) []s
pathLabel := rootPathLabelFromFSPath(dir.Dir)
totalBytes := dir.BytesRead + dir.BytesWritten
item := syscallTreemapItem{
- Name: pathLabel,
- Count: dir.Accesses,
- Bytes: totalBytes,
+ Name: pathLabel,
+ Count: dir.Accesses,
+ Bytes: totalBytes,
+ Duration: dir.TotalLatencyNs,
Detail: fmt.Sprintf(
"dir %s, files %d, read %s, write %s, max %s",
dir.Dir,
@@ -199,9 +202,10 @@ func buildProcessesTreemapItems(snap *statsengine.Snapshot, metric bubbleMetric)
label = fmt.Sprintf("%d:%s", proc.PID, comm)
}
item := syscallTreemapItem{
- Name: label,
- Count: proc.Syscalls,
- Bytes: proc.Bytes,
+ Name: label,
+ Count: proc.Syscalls,
+ Bytes: proc.Bytes,
+ Duration: proc.TotalLatencyNs,
Detail: fmt.Sprintf(
"pid %d, rate %.1f/s, avg %s",
proc.PID,
@@ -231,10 +235,14 @@ func buildProcessesTreemapItems(snap *statsengine.Snapshot, metric bubbleMetric)
}
func treemapValue(item syscallTreemapItem, metric bubbleMetric) uint64 {
- if metric == bubbleMetricBytes {
+ switch metric {
+ case bubbleMetricBytes:
return item.Bytes
+ case bubbleMetricDuration:
+ return item.Duration
+ default:
+ return item.Count
}
- return item.Count
}
func layoutSyscallTreemap(items []syscallTreemapItem, x, y, w, h int) []syscallTreemapTile {
@@ -421,13 +429,14 @@ func treemapStatusLine(items []syscallTreemapItem, selected int, metric bubbleMe
}
selected = clampOffset(selected, len(items))
item := items[selected]
- metricValue := item.Count
- if metric == bubbleMetricBytes {
- metricValue = item.Bytes
- }
- metricText := fmt.Sprintf("%d", metricValue)
- if metric == bubbleMetricBytes {
- metricText = formatBytes(float64(metricValue))
+ var metricText string
+ switch metric {
+ case bubbleMetricBytes:
+ metricText = formatBytes(float64(item.Bytes))
+ case bubbleMetricDuration:
+ metricText = formatDurationUintNs(item.Duration)
+ default:
+ metricText = fmt.Sprintf("%d", item.Count)
}
status := fmt.Sprintf(
"sel:%d/%d %s | %s=%s | bytes=%s",
@@ -445,10 +454,14 @@ func treemapStatusLine(items []syscallTreemapItem, selected int, metric bubbleMe
}
func treemapMetricLabel(metric bubbleMetric) string {
- if metric == bubbleMetricBytes {
+ switch metric {
+ case bubbleMetricBytes:
return "bytes"
+ case bubbleMetricDuration:
+ return "duration"
+ default:
+ return "events"
}
- return "events"
}
func treemapPalette(isDark bool) []color.Color {
diff --git a/internal/tui/flamegraph/controls.go b/internal/tui/flamegraph/controls.go
index 2033416..bd588b3 100644
--- a/internal/tui/flamegraph/controls.go
+++ b/internal/tui/flamegraph/controls.go
@@ -57,8 +57,16 @@ func (m *Model) cycleFieldOrder() {
}
func (m *Model) toggleCountField() {
- next := "bytes"
- if m.countField == "bytes" {
+ // 3-way cycle: count → bytes → duration → count.
+ // durationToPrev (inter-syscall gap) is reachable via the CLI flag but
+ // kept out of the toolbar cycle for now.
+ var next string
+ switch m.countField {
+ case "count":
+ next = "bytes"
+ case "bytes":
+ next = "duration"
+ default:
next = "count"
}
if m.liveTrie != nil {
@@ -168,6 +176,8 @@ func (m Model) countFieldLabel() string {
return "events"
case "bytes":
return "bytes"
+ case "duration":
+ return "duration"
default:
return m.countField
}
diff --git a/internal/tui/flamegraph/model_test.go b/internal/tui/flamegraph/model_test.go
index c2626cd..e864e88 100644
--- a/internal/tui/flamegraph/model_test.go
+++ b/internal/tui/flamegraph/model_test.go
@@ -987,12 +987,20 @@ func TestControlMetricToggleReconfiguresLiveTrieCountField(t *testing.T) {
}
m = pressFlameKey(t, m, tea.KeyPressMsg{Code: []rune{'b'}[0], Text: "b"})
- if got, want := m.countField, "count"; got != want {
+ if got, want := m.countField, "duration"; got != want {
t.Fatalf("expected model count field %q after second toggle, got %q", want, got)
}
- if got, want := liveTrie.CountField(), "count"; got != want {
+ if got, want := liveTrie.CountField(), "duration"; got != want {
t.Fatalf("expected live trie count field %q after second toggle, got %q", want, got)
}
+
+ m = pressFlameKey(t, m, tea.KeyPressMsg{Code: []rune{'b'}[0], Text: "b"})
+ if got, want := m.countField, "count"; got != want {
+ t.Fatalf("expected model count field %q after third toggle, got %q", want, got)
+ }
+ if got, want := liveTrie.CountField(), "count"; got != want {
+ t.Fatalf("expected live trie count field %q after third toggle, got %q", want, got)
+ }
}
func TestNewModelAlignsPresetIndexToLiveTrieFields(t *testing.T) {