From f6135befedb7da8603ecff2c59b155effd0183db Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Thu, 5 Mar 2026 23:51:59 +0200 Subject: Add package-level docs across internal packages --- internal/flamegraph/doc.go | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 internal/flamegraph/doc.go (limited to 'internal/flamegraph') diff --git a/internal/flamegraph/doc.go b/internal/flamegraph/doc.go new file mode 100644 index 0000000..8ff27d2 --- /dev/null +++ b/internal/flamegraph/doc.go @@ -0,0 +1,2 @@ +// Package flamegraph builds aggregated call trees and rendering inputs for I/O flamegraphs. +package flamegraph -- cgit v1.2.3 From acd91c7c11658360e0540f9d550f1fe0da167109 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Thu, 5 Mar 2026 23:53:01 +0200 Subject: Normalize Go import grouping with local ior section --- internal/flamegraph/counter.go | 1 + internal/flamegraph/iordata.go | 7 ++++--- internal/flamegraph/iordata_test.go | 3 ++- internal/flamegraph/iordatacollector.go | 3 ++- internal/flamegraph/livetrie.go | 3 ++- internal/flamegraph/livetrie_test.go | 7 ++++--- internal/flamegraph/worker.go | 3 ++- 7 files changed, 17 insertions(+), 10 deletions(-) (limited to 'internal/flamegraph') diff --git a/internal/flamegraph/counter.go b/internal/flamegraph/counter.go index ae727d4..539d017 100644 --- a/internal/flamegraph/counter.go +++ b/internal/flamegraph/counter.go @@ -10,6 +10,7 @@ import ( // - Duration is the syscall runtime on the same thread. // - DurationToPrev is the inter-syscall gap on the same thread and is attributed // to the current node; there is no separate "idle" pseudo-node. +// // Bytes is only populated for read/write/transfer syscalls. type Counter struct { Count uint64 diff --git a/internal/flamegraph/iordata.go b/internal/flamegraph/iordata.go index 61a65a9..13b78fd 100644 --- a/internal/flamegraph/iordata.go +++ b/internal/flamegraph/iordata.go @@ -5,14 +5,15 @@ import ( "encoding/gob" "fmt" "io" - "ior/internal/event" - "ior/internal/file" - "ior/internal/types" "iter" "os" "strings" "time" + "ior/internal/event" + "ior/internal/file" + "ior/internal/types" + // Is there a zstd library part of Go 1.25 "github.com/DataDog/zstd" ) diff --git a/internal/flamegraph/iordata_test.go b/internal/flamegraph/iordata_test.go index 54f1ed5..5e95976 100644 --- a/internal/flamegraph/iordata_test.go +++ b/internal/flamegraph/iordata_test.go @@ -2,9 +2,10 @@ package flamegraph import ( "bytes" - "ior/internal/types" "syscall" "testing" + + "ior/internal/types" ) func counterAt(iod iorData, path pathType, traceID traceIdType, comm commType, pid pidType, tid tidType, flags flagsType) (Counter, bool) { diff --git a/internal/flamegraph/iordatacollector.go b/internal/flamegraph/iordatacollector.go index 9e92b63..a2ae731 100644 --- a/internal/flamegraph/iordatacollector.go +++ b/internal/flamegraph/iordatacollector.go @@ -3,9 +3,10 @@ package flamegraph import ( "context" "fmt" - "ior/internal/event" "runtime" "sync" + + "ior/internal/event" ) type IorDataCollector struct { diff --git a/internal/flamegraph/livetrie.go b/internal/flamegraph/livetrie.go index 100e03b..db46af5 100644 --- a/internal/flamegraph/livetrie.go +++ b/internal/flamegraph/livetrie.go @@ -3,12 +3,13 @@ package flamegraph import ( "encoding/json" "fmt" - "ior/internal/event" "slices" "sort" "strings" "sync" "sync/atomic" + + "ior/internal/event" ) const liveTrieMinFraction = 0.001 diff --git a/internal/flamegraph/livetrie_test.go b/internal/flamegraph/livetrie_test.go index 1315c71..e569e00 100644 --- a/internal/flamegraph/livetrie_test.go +++ b/internal/flamegraph/livetrie_test.go @@ -4,15 +4,16 @@ import ( "bytes" "encoding/json" "fmt" - "ior/internal/event" - "ior/internal/file" - "ior/internal/types" "os" "runtime" "sync" "sync/atomic" "testing" "time" + + "ior/internal/event" + "ior/internal/file" + "ior/internal/types" ) func TestLiveTrieIngestAndSnapshotRoundTrip(t *testing.T) { diff --git a/internal/flamegraph/worker.go b/internal/flamegraph/worker.go index 0f49568..1e8c639 100644 --- a/internal/flamegraph/worker.go +++ b/internal/flamegraph/worker.go @@ -2,8 +2,9 @@ package flamegraph import ( "context" - "ior/internal/event" "sync" + + "ior/internal/event" ) type worker struct { -- cgit v1.2.3 From ef12ce837176bd21deb455eb50a6c839af02b510 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Fri, 6 Mar 2026 13:36:51 +0200 Subject: Add live flamegraph test modes and dynamic synthetic live feed --- internal/flamegraph/livetrie.go | 8 ++- internal/flamegraph/livetrie_test.go | 64 +++++++++++++++++++ internal/flamegraph/testfixture.go | 120 +++++++++++++++++++++++++++++++++++ 3 files changed, 190 insertions(+), 2 deletions(-) create mode 100644 internal/flamegraph/testfixture.go (limited to 'internal/flamegraph') diff --git a/internal/flamegraph/livetrie.go b/internal/flamegraph/livetrie.go index db46af5..0d42b6b 100644 --- a/internal/flamegraph/livetrie.go +++ b/internal/flamegraph/livetrie.go @@ -88,6 +88,12 @@ func (lt *LiveTrie) invalidateCache() { // Ingest adds one event pair into the live trie and recycles the pair. func (lt *LiveTrie) Ingest(ep *event.Pair) { record := eventPairToRecord(ep) + lt.AddRecord(record) + ep.Recycle() +} + +// AddRecord adds one already-decoded flamegraph record into the live trie. +func (lt *LiveTrie) AddRecord(record IterRecord) { value := record.Cnt.ValueByName(lt.countField) lt.mu.Lock() @@ -95,8 +101,6 @@ func (lt *LiveTrie) Ingest(ep *event.Pair) { lt.addLocked(frames, value) lt.version.Add(1) lt.mu.Unlock() - - ep.Recycle() } // Reset clears the trie so live snapshots start from a new baseline. diff --git a/internal/flamegraph/livetrie_test.go b/internal/flamegraph/livetrie_test.go index e569e00..632f668 100644 --- a/internal/flamegraph/livetrie_test.go +++ b/internal/flamegraph/livetrie_test.go @@ -61,6 +61,70 @@ func TestLiveTrieVersionIncrementsPerIngest(t *testing.T) { } } +func TestLiveTrieAddRecordIncrementsVersion(t *testing.T) { + lt := NewLiveTrie([]string{"comm", "path", "tracepoint"}, "count") + lt.AddRecord(IterRecord{ + Path: "/tmp/demo/read", + TraceID: types.SYS_ENTER_READ, + Comm: "demo", + Pid: 1001, + Tid: 1001, + Cnt: Counter{Count: 7, Duration: 70, DurationToPrev: 14, Bytes: 28}, + }) + + if got := lt.Version(); got != 1 { + t.Fatalf("version = %d, want 1", got) + } + snap := decodeLiveSnapshot(t, lt) + if snap.Total != 7 { + t.Fatalf("root total = %d, want 7", snap.Total) + } +} + +func TestSeedTestFlameDataBuildsStaticFixture(t *testing.T) { + lt := NewLiveTrie([]string{"comm", "path", "tracepoint"}, "count") + SeedTestFlameData(lt) + + if got := lt.Version(); got == 0 { + t.Fatalf("expected seed fixture to add records") + } + snap := decodeLiveSnapshot(t, lt) + if snap.Total == 0 { + t.Fatalf("expected non-empty seeded snapshot") + } + if findSnapshotChild(&snap, "api") == nil { + t.Fatalf("expected seeded snapshot to include api branch") + } + if findSnapshotChild(&snap, "worker") == nil { + t.Fatalf("expected seeded snapshot to include worker branch") + } +} + +func TestSeedTestLiveFlameDataVariesByTick(t *testing.T) { + lt := NewLiveTrie([]string{"comm", "path", "tracepoint"}, "count") + + SeedTestLiveFlameData(lt, 0) + snapTick0 := decodeLiveSnapshot(t, lt) + apiTick0 := findSnapshotPath(t, &snapTick0, "api").Total + workerTick0 := findSnapshotPath(t, &snapTick0, "worker").Total + + lt.Reset() + SeedTestLiveFlameData(lt, 1) + snapTick1 := decodeLiveSnapshot(t, lt) + apiTick1 := findSnapshotPath(t, &snapTick1, "api").Total + workerTick1 := findSnapshotPath(t, &snapTick1, "worker").Total + + if apiTick0 == apiTick1 && workerTick0 == workerTick1 { + t.Fatalf("expected phase shift to alter branch totals, got api=%d worker=%d for both ticks", apiTick0, workerTick0) + } + if apiTick0 <= workerTick0 { + t.Fatalf("expected api to dominate at tick 0, got api=%d worker=%d", apiTick0, workerTick0) + } + if workerTick1 <= apiTick1 { + t.Fatalf("expected worker to dominate at tick 1, got worker=%d api=%d", workerTick1, apiTick1) + } +} + func TestLiveTrieResetClearsDataAndAdvancesVersion(t *testing.T) { lt := NewLiveTrie([]string{"comm"}, "count") lt.Ingest(newTestPair("svc", 42, 1001, "/tmp/a", 1, 1, 1)) diff --git a/internal/flamegraph/testfixture.go b/internal/flamegraph/testfixture.go new file mode 100644 index 0000000..2774925 --- /dev/null +++ b/internal/flamegraph/testfixture.go @@ -0,0 +1,120 @@ +package flamegraph + +import ( + "ior/internal/types" + "strings" +) + +// SeedTestFlameData populates a deterministic static flamegraph fixture. +// Intended for keyboard-navigation validation in TUI test-flame mode. +func SeedTestFlameData(liveTrie *LiveTrie) { + if liveTrie == nil { + return + } + for _, record := range testFlameRecords() { + liveTrie.AddRecord(record) + } +} + +// SeedTestLiveFlameData populates deterministic synthetic data for a given live tick. +// The data shape stays navigable while branch weights shift by phase so the +// terminal flamegraph visibly changes over time. +func SeedTestLiveFlameData(liveTrie *LiveTrie, tick uint64) { + if liveTrie == nil { + return + } + phase := tick % 4 + for _, base := range testFlameRecords() { + weight := liveTestWeight(base, phase) + liveTrie.AddRecord(withTestFlameWeight(base, weight)) + } +} + +func testFlameRecords() []IterRecord { + return []IterRecord{ + newTestFlameRecord("api", "/srv/api/lib/http/client/read", 2001, 2201, types.SYS_ENTER_READ, 180), + newTestFlameRecord("api", "/srv/api/lib/json/encode/write", 2001, 2201, types.SYS_ENTER_WRITE, 120), + newTestFlameRecord("api", "/srv/api/storage/postgres/query/read", 2001, 2201, types.SYS_ENTER_READ, 240), + newTestFlameRecord("api", "/srv/api/storage/postgres/commit/fsync", 2001, 2201, types.SYS_ENTER_FSYNC, 70), + newTestFlameRecord("worker", "/srv/worker/queue/pop/read", 2002, 2202, types.SYS_ENTER_READ, 160), + newTestFlameRecord("worker", "/srv/worker/queue/push/write", 2002, 2202, types.SYS_ENTER_WRITE, 145), + newTestFlameRecord("worker", "/srv/worker/cache/redis/get/read", 2002, 2202, types.SYS_ENTER_READ, 95), + newTestFlameRecord("worker", "/srv/worker/cache/redis/set/write", 2002, 2202, types.SYS_ENTER_WRITE, 90), + newTestFlameRecord("ingest", "/srv/ingest/parser/csv/read", 2003, 2203, types.SYS_ENTER_READ, 110), + newTestFlameRecord("ingest", "/srv/ingest/parser/csv/normalize/write", 2003, 2203, types.SYS_ENTER_WRITE, 80), + newTestFlameRecord("ingest", "/srv/ingest/uploader/s3/put/writev", 2003, 2203, types.SYS_ENTER_WRITEV, 75), + newTestFlameRecord("batch", "/srv/batch/jobs/report/open", 2004, 2204, types.SYS_ENTER_OPENAT, 55), + newTestFlameRecord("batch", "/srv/batch/jobs/report/close", 2004, 2204, types.SYS_ENTER_CLOSE, 35), + newTestFlameRecord("batch", "/srv/batch/jobs/report/rename", 2004, 2204, types.SYS_ENTER_RENAMEAT, 20), + } +} + +func newTestFlameRecord(comm, path string, pid, tid uint32, traceID types.TraceId, weight uint64) IterRecord { + return IterRecord{ + Path: path, + TraceID: traceID, + Comm: comm, + Pid: pid, + Tid: tid, + Cnt: Counter{ + Count: weight, + Duration: weight * 1000, + DurationToPrev: weight * 350, + Bytes: weight * 4096, + }, + } +} + +func withTestFlameWeight(record IterRecord, weight uint64) IterRecord { + record.Cnt = Counter{ + Count: weight, + Duration: weight * 1000, + DurationToPrev: weight * 350, + Bytes: weight * 4096, + } + return record +} + +func liveTestWeight(record IterRecord, phase uint64) uint64 { + base := record.Cnt.Count + multiplier := uint64(1) + + switch phase { + case 0: + if record.Comm == "api" { + multiplier += 4 + } + if strings.Contains(record.Path, "/lib/") { + multiplier += 2 + } + case 1: + if record.Comm == "worker" { + multiplier += 4 + } + if strings.Contains(record.Path, "/queue/") { + multiplier += 2 + } + case 2: + if record.Comm == "ingest" { + multiplier += 4 + } + if strings.Contains(record.Path, "/uploader/") || strings.Contains(record.Path, "/parser/") { + multiplier += 2 + } + case 3: + if record.Comm == "batch" { + multiplier += 4 + } + if strings.Contains(record.Path, "/report/") { + multiplier += 2 + } + } + + if strings.Contains(record.Path, "/storage/") && phase%2 == 0 { + multiplier++ + } + if strings.Contains(record.Path, "/cache/") && phase%2 == 1 { + multiplier++ + } + return base * multiplier +} -- cgit v1.2.3 From aa4f638206b9b79de267f9a1daab7ec6698b241d Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Fri, 6 Mar 2026 14:21:30 +0200 Subject: Fix real live flamegraph key handling and startup viewport sync --- internal/flamegraph/livetrie.go | 78 +++++++++++++++++++++++++++++++----- internal/flamegraph/livetrie_test.go | 35 ++++++++++++++++ 2 files changed, 104 insertions(+), 9 deletions(-) (limited to 'internal/flamegraph') diff --git a/internal/flamegraph/livetrie.go b/internal/flamegraph/livetrie.go index 0d42b6b..13d7de9 100644 --- a/internal/flamegraph/livetrie.go +++ b/internal/flamegraph/livetrie.go @@ -12,7 +12,11 @@ import ( "ior/internal/event" ) -const liveTrieMinFraction = 0.001 +const ( + liveTrieMinFraction = 0.001 + liveTrieMinVisibleChildrenWhenPruned = 8 + liveTrieVisibleChildrenFallbackMaxDepth = 1 +) type trieSnapshot struct { Name string `json:"n"` @@ -244,29 +248,45 @@ func subtreeTotal(node *trieNode) uint64 { } func buildSnapshot(node *trieNode, depth int, minFraction float64, rootTotal uint64) *trieSnapshot { - snapshot, _ := buildSnapshotWithTotal(node, depth, minFraction, rootTotal) + snapshot, _ := buildSnapshotWithTotal(node, depth, minFraction, rootTotal, false) return snapshot } -func buildSnapshotWithTotal(node *trieNode, depth int, minFraction float64, rootTotal uint64) (*trieSnapshot, uint64) { +type childSnapshotState struct { + node *trieNode + snapshot *trieSnapshot + total uint64 +} + +func buildSnapshotWithTotal(node *trieNode, depth int, minFraction float64, rootTotal uint64, forceKeep bool) (*trieSnapshot, uint64) { total := node.value children := slices.Clone(node.children) sort.Slice(children, func(i, j int) bool { return children[i].name < children[j].name }) - childSnapshots := make([]*trieSnapshot, 0, len(children)) + childStates := make([]childSnapshotState, 0, len(children)) for _, child := range children { - childSnapshot, childTotal := buildSnapshotWithTotal(child, depth+1, minFraction, rootTotal) + childSnapshot, childTotal := buildSnapshotWithTotal(child, depth+1, minFraction, rootTotal, false) total += childTotal - if childSnapshot != nil { - childSnapshots = append(childSnapshots, childSnapshot) - } + childStates = append(childStates, childSnapshotState{ + node: child, + snapshot: childSnapshot, + total: childTotal, + }) } - if depth > 0 && rootTotal > 0 && float64(total)/float64(rootTotal) < minFraction { + if !forceKeep && depth > 0 && rootTotal > 0 && float64(total)/float64(rootTotal) < minFraction { return nil, total } + ensureFallbackVisibleChildren(childStates, depth, minFraction, rootTotal) + + childSnapshots := make([]*trieSnapshot, 0, len(childStates)) + for _, child := range childStates { + if child.snapshot != nil { + childSnapshots = append(childSnapshots, child.snapshot) + } + } snapshot := &trieSnapshot{ Name: node.name, @@ -278,3 +298,43 @@ func buildSnapshotWithTotal(node *trieNode, depth int, minFraction float64, root } return snapshot, total } + +func ensureFallbackVisibleChildren(children []childSnapshotState, depth int, minFraction float64, rootTotal uint64) { + if depth > liveTrieVisibleChildrenFallbackMaxDepth { + return + } + visible := 0 + for _, child := range children { + if child.snapshot != nil { + visible++ + } + } + if visible > 0 { + return + } + + candidates := make([]int, 0, len(children)) + for idx, child := range children { + if child.total > 0 { + candidates = append(candidates, idx) + } + } + sort.Slice(candidates, func(i, j int) bool { + left := children[candidates[i]] + right := children[candidates[j]] + if left.total == right.total { + return left.node.name < right.node.name + } + return left.total > right.total + }) + + limit := liveTrieMinVisibleChildrenWhenPruned + if len(candidates) < limit { + limit = len(candidates) + } + for i := 0; i < limit; i++ { + idx := candidates[i] + forced, _ := buildSnapshotWithTotal(children[idx].node, depth+1, minFraction, rootTotal, true) + children[idx].snapshot = forced + } +} diff --git a/internal/flamegraph/livetrie_test.go b/internal/flamegraph/livetrie_test.go index 632f668..c5ed32c 100644 --- a/internal/flamegraph/livetrie_test.go +++ b/internal/flamegraph/livetrie_test.go @@ -221,6 +221,41 @@ func TestLiveTrieSnapshotJSONPrunesTinyNodes(t *testing.T) { } } +func TestLiveTrieSnapshotJSONKeepsFallbackChildrenWhenAllAreTinyAtRoot(t *testing.T) { + lt := NewLiveTrie([]string{"comm"}, "count") + const total = 6000 + for i := 0; i < total; i++ { + comm := fmt.Sprintf("svc-%04d", i) + lt.Ingest(newTestPair(comm, 42, uint32(100000+i), "/tmp/a", 1, 1, 1)) + } + + snap := decodeLiveSnapshot(t, lt) + if len(snap.Children) == 0 { + t.Fatalf("expected fallback root children when pruning would hide every branch") + } + if got, want := len(snap.Children), liveTrieMinVisibleChildrenWhenPruned; got != want { + t.Fatalf("expected fallback to keep %d root children, got %d", want, got) + } +} + +func TestLiveTrieSnapshotJSONKeepsFallbackChildrenAtDepthOne(t *testing.T) { + lt := NewLiveTrie([]string{"comm", "pid"}, "count") + const total = 6000 + for i := 0; i < total; i++ { + pid := uint32(100000 + i) + lt.Ingest(newTestPair("svc", pid, pid, "/tmp/a", 1, 1, 1)) + } + + snap := decodeLiveSnapshot(t, lt) + commNode := findSnapshotPath(t, &snap, "svc") + if len(commNode.Children) == 0 { + t.Fatalf("expected fallback depth-one children for pid branches") + } + if got, want := len(commNode.Children), liveTrieMinVisibleChildrenWhenPruned; got != want { + t.Fatalf("expected fallback to keep %d depth-one children, got %d", want, got) + } +} + func TestLiveTrieConcurrentIngestAndSnapshot(t *testing.T) { lt := NewLiveTrie([]string{"comm", "pid"}, "count") -- cgit v1.2.3 From 3e08a3d199fdf603b7c0a4002ca9822b6ecf2575 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Fri, 6 Mar 2026 14:33:52 +0200 Subject: flamegraph: make esc undo one zoom level and aggregate syscalls by default --- internal/flamegraph/livehtml.go | 7 ++--- internal/flamegraph/livehtml_browser_test.go | 2 +- internal/flamegraph/livehtml_interaction_test.go | 2 +- internal/flamegraph/livetrie_test.go | 33 ++++++++++++++++++++++++ 4 files changed, 39 insertions(+), 5 deletions(-) (limited to 'internal/flamegraph') diff --git a/internal/flamegraph/livehtml.go b/internal/flamegraph/livehtml.go index 90a6d3d..71b955e 100644 --- a/internal/flamegraph/livehtml.go +++ b/internal/flamegraph/livehtml.go @@ -116,7 +116,7 @@ const liveHTML = ` - + LIVE @@ -146,10 +146,11 @@ const liveHTML = ` resetBaselineBtn: document.getElementById('btn-reset-baseline'), toggleOrderBtn: document.getElementById('btn-toggle-order'), orderPresets: [ - 'comm,path,tracepoint', + 'comm,tracepoint,path', 'path,tracepoint,comm', 'tracepoint,comm,path', - 'pid,path,tracepoint' + 'pid,tracepoint,path', + 'comm,path,tracepoint' ], orderIndex: 0, cfg: { diff --git a/internal/flamegraph/livehtml_browser_test.go b/internal/flamegraph/livehtml_browser_test.go index c7a16c7..10252a9 100644 --- a/internal/flamegraph/livehtml_browser_test.go +++ b/internal/flamegraph/livehtml_browser_test.go @@ -166,7 +166,7 @@ global.prompt = function(){ return ""; }; global.fetch = function() { return Promise.resolve({ ok: true, - json: function() { return Promise.resolve({ fields: ["comm", "path", "tracepoint"], snapshot: { n: "", v: 0, t: 0 } }); }, + json: function() { return Promise.resolve({ fields: ["comm", "tracepoint", "path"], snapshot: { n: "", v: 0, t: 0 } }); }, text: function() { return Promise.resolve("{\"n\":\"\",\"v\":0,\"t\":0}"); } }); }; diff --git a/internal/flamegraph/livehtml_interaction_test.go b/internal/flamegraph/livehtml_interaction_test.go index 0de1466..4c947f5 100644 --- a/internal/flamegraph/livehtml_interaction_test.go +++ b/internal/flamegraph/livehtml_interaction_test.go @@ -574,7 +574,7 @@ global.prompt = function(){ return ""; }; global.fetch = function() { return Promise.resolve({ ok: true, - json: function() { return Promise.resolve({ fields: ["comm", "path", "tracepoint"], snapshot: { n: "", v: 0, t: 0 } }); }, + json: function() { return Promise.resolve({ fields: ["comm", "tracepoint", "path"], snapshot: { n: "", v: 0, t: 0 } }); }, text: function() { return Promise.resolve("{\"n\":\"\",\"v\":0,\"t\":0}"); } }); }; diff --git a/internal/flamegraph/livetrie_test.go b/internal/flamegraph/livetrie_test.go index c5ed32c..71f645c 100644 --- a/internal/flamegraph/livetrie_test.go +++ b/internal/flamegraph/livetrie_test.go @@ -48,6 +48,39 @@ func TestLiveTrieIngestIsAdditive(t *testing.T) { } } +func TestLiveTrieCommTracepointPathAggregatesSameSyscallAcrossPaths(t *testing.T) { + lt := NewLiveTrie([]string{"comm", "tracepoint", "path"}, "count") + lt.AddRecord(IterRecord{ + Path: "/srv/a", + TraceID: types.SYS_ENTER_READ, + Comm: "svc", + Pid: 1001, + Tid: 1001, + Cnt: Counter{Count: 1}, + }) + lt.AddRecord(IterRecord{ + Path: "/srv/b", + TraceID: types.SYS_ENTER_READ, + Comm: "svc", + Pid: 1002, + Tid: 1002, + Cnt: Counter{Count: 1}, + }) + + snap := decodeLiveSnapshot(t, lt) + commNode := findSnapshotPath(t, &snap, "svc") + if len(commNode.Children) != 1 { + t.Fatalf("expected one syscall child under comm node, got %d", len(commNode.Children)) + } + syscallNode := commNode.Children[0] + if got, want := syscallNode.Name, "enter_read"; got != want { + t.Fatalf("syscall child name = %q, want %q", got, want) + } + if got, want := syscallNode.Total, uint64(2); got != want { + t.Fatalf("syscall aggregate total = %d, want %d", got, want) + } +} + func TestLiveTrieVersionIncrementsPerIngest(t *testing.T) { lt := NewLiveTrie([]string{"comm"}, "count") if got := lt.Version(); got != 0 { -- cgit v1.2.3 From 4ff17c30120d657b966f8a55188ba167dc875e64 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Fri, 6 Mar 2026 15:21:01 +0200 Subject: feat(tui): add flamegraph bytes metric toggle --- internal/flamegraph/livetrie.go | 39 +++++++++++++++++++++++++++++ internal/flamegraph/livetrie_test.go | 48 ++++++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+) (limited to 'internal/flamegraph') diff --git a/internal/flamegraph/livetrie.go b/internal/flamegraph/livetrie.go index 13d7de9..9f1fd91 100644 --- a/internal/flamegraph/livetrie.go +++ b/internal/flamegraph/livetrie.go @@ -42,6 +42,9 @@ type LiveTrie struct { // NewLiveTrie constructs an empty live trie with the configured frame/count fields. func NewLiveTrie(fields []string, countField string) *LiveTrie { + if !isLiveTrieCountField(countField) { + countField = "count" + } return &LiveTrie{ root: &trieNode{ childMap: make(map[string]*trieNode), @@ -123,6 +126,33 @@ func (lt *LiveTrie) Fields() []string { return out } +// CountField returns the active metric used to aggregate node values. +func (lt *LiveTrie) CountField() string { + lt.mu.RLock() + field := lt.countField + lt.mu.RUnlock() + return field +} + +// SetCountField changes the active aggregation metric and starts a new baseline. +func (lt *LiveTrie) SetCountField(countField string) error { + field := strings.TrimSpace(countField) + if !isLiveTrieCountField(field) { + return fmt.Errorf("invalid count field %q", countField) + } + + lt.mu.Lock() + if lt.countField == field { + lt.mu.Unlock() + return nil + } + lt.countField = field + lt.resetLocked() + lt.mu.Unlock() + lt.invalidateCache() + return nil +} + // Reconfigure changes frame fields and clears accumulated data for a new baseline. func (lt *LiveTrie) Reconfigure(fields []string) error { normalized, err := normalizeLiveTrieFields(fields) @@ -239,6 +269,15 @@ func isLiveTrieField(field string) bool { } } +func isLiveTrieCountField(field string) bool { + switch field { + case "count", "duration", "durationToPrev", "bytes": + return true + default: + return false + } +} + func subtreeTotal(node *trieNode) uint64 { total := node.value for _, child := range node.children { diff --git a/internal/flamegraph/livetrie_test.go b/internal/flamegraph/livetrie_test.go index 71f645c..53bdf1f 100644 --- a/internal/flamegraph/livetrie_test.go +++ b/internal/flamegraph/livetrie_test.go @@ -223,6 +223,54 @@ func TestLiveTrieReconfigureRejectsInvalidFields(t *testing.T) { } } +func TestLiveTrieSetCountFieldSwitchesMetricAndResetsBaseline(t *testing.T) { + lt := NewLiveTrie([]string{"comm"}, "count") + lt.Ingest(newTestPair("svc", 42, 1001, "/tmp/a", 10, 1, 64)) + + initial := decodeLiveSnapshot(t, lt) + if got, want := initial.Total, uint64(1); got != want { + t.Fatalf("count snapshot total = %d, want %d", got, want) + } + + if err := lt.SetCountField("bytes"); err != nil { + t.Fatalf("set count field: %v", err) + } + if got, want := lt.CountField(), "bytes"; got != want { + t.Fatalf("count field = %q, want %q", got, want) + } + + empty := decodeLiveSnapshot(t, lt) + if got := empty.Total; got != 0 { + t.Fatalf("expected reset baseline after metric switch, total=%d", got) + } + + lt.Ingest(newTestPair("svc", 42, 1002, "/tmp/b", 10, 1, 64)) + bytesSnap := decodeLiveSnapshot(t, lt) + if got, want := bytesSnap.Total, uint64(64); got != want { + t.Fatalf("bytes snapshot total = %d, want %d", got, want) + } + leaf := findSnapshotPath(t, &bytesSnap, "svc") + if got, want := leaf.Total, uint64(64); got != want { + t.Fatalf("bytes leaf total = %d, want %d", got, want) + } +} + +func TestLiveTrieSetCountFieldRejectsInvalidValue(t *testing.T) { + lt := NewLiveTrie([]string{"comm"}, "count") + lt.Ingest(newTestPair("svc", 42, 1001, "/tmp/a", 1, 1, 1)) + beforeVersion := lt.Version() + + if err := lt.SetCountField("bogus"); err == nil { + t.Fatalf("expected invalid count field error") + } + if got, want := lt.CountField(), "count"; got != want { + t.Fatalf("count field changed unexpectedly: got %q want %q", got, want) + } + if got := lt.Version(); got != beforeVersion { + t.Fatalf("version changed on invalid count field: got %d want %d", got, beforeVersion) + } +} + func TestLiveTrieSnapshotJSONCaching(t *testing.T) { lt := NewLiveTrie([]string{"comm"}, "count") lt.Ingest(newTestPair("svc", 42, 1001, "/tmp/a", 1, 1, 1)) -- cgit v1.2.3 From 99b02bf8c389a793df5d5986db05eed7e459f7b1 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Fri, 6 Mar 2026 15:35:24 +0200 Subject: refactor: remove web flamegrapher and keep TUI-only --- internal/flamegraph/doc.go | 2 +- internal/flamegraph/iordatacollector.go | 65 -- internal/flamegraph/layout.go | 78 --- internal/flamegraph/layout_test.go | 77 --- internal/flamegraph/livehtml.go | 842 ----------------------- internal/flamegraph/livehtml_browser_test.go | 314 --------- internal/flamegraph/livehtml_interaction_test.go | 615 ----------------- internal/flamegraph/liveserver.go | 314 --------- internal/flamegraph/liveserver_open_test.go | 179 ----- internal/flamegraph/liveserver_test.go | 380 ---------- internal/flamegraph/nativejson.go | 86 --- internal/flamegraph/nativejson_test.go | 75 -- internal/flamegraph/nativesvg.go | 97 --- internal/flamegraph/nativesvg_test.go | 60 -- internal/flamegraph/svgwriter.go | 151 ---- internal/flamegraph/svgwriter_js.go | 212 ------ internal/flamegraph/svgwriter_jscode.go | 214 ------ internal/flamegraph/svgwriter_test.go | 112 --- internal/flamegraph/webserver.go | 199 ------ internal/flamegraph/webserver_autoreload_test.go | 37 - internal/flamegraph/webserver_timeout_test.go | 43 -- internal/flamegraph/worker.go | 34 - 22 files changed, 1 insertion(+), 4185 deletions(-) delete mode 100644 internal/flamegraph/iordatacollector.go delete mode 100644 internal/flamegraph/layout.go delete mode 100644 internal/flamegraph/layout_test.go delete mode 100644 internal/flamegraph/livehtml.go delete mode 100644 internal/flamegraph/livehtml_browser_test.go delete mode 100644 internal/flamegraph/livehtml_interaction_test.go delete mode 100644 internal/flamegraph/liveserver.go delete mode 100644 internal/flamegraph/liveserver_open_test.go delete mode 100644 internal/flamegraph/liveserver_test.go delete mode 100644 internal/flamegraph/nativejson.go delete mode 100644 internal/flamegraph/nativejson_test.go delete mode 100644 internal/flamegraph/nativesvg.go delete mode 100644 internal/flamegraph/nativesvg_test.go delete mode 100644 internal/flamegraph/svgwriter.go delete mode 100644 internal/flamegraph/svgwriter_js.go delete mode 100644 internal/flamegraph/svgwriter_jscode.go delete mode 100644 internal/flamegraph/svgwriter_test.go delete mode 100644 internal/flamegraph/webserver.go delete mode 100644 internal/flamegraph/webserver_autoreload_test.go delete mode 100644 internal/flamegraph/webserver_timeout_test.go delete mode 100644 internal/flamegraph/worker.go (limited to 'internal/flamegraph') diff --git a/internal/flamegraph/doc.go b/internal/flamegraph/doc.go index 8ff27d2..02429d3 100644 --- a/internal/flamegraph/doc.go +++ b/internal/flamegraph/doc.go @@ -1,2 +1,2 @@ -// Package flamegraph builds aggregated call trees and rendering inputs for I/O flamegraphs. +// Package flamegraph provides TUI flamegraph aggregation primitives. package flamegraph diff --git a/internal/flamegraph/iordatacollector.go b/internal/flamegraph/iordatacollector.go deleted file mode 100644 index a2ae731..0000000 --- a/internal/flamegraph/iordatacollector.go +++ /dev/null @@ -1,65 +0,0 @@ -package flamegraph - -import ( - "context" - "fmt" - "runtime" - "sync" - - "ior/internal/event" -) - -type IorDataCollector struct { - flamegraphName string - Ch chan *event.Pair - Done chan error - workers []worker -} - -func New(flamegraphName ...string) IorDataCollector { - name := "default" - if len(flamegraphName) > 0 && flamegraphName[0] != "" { - name = flamegraphName[0] - } - - f := IorDataCollector{ - flamegraphName: name, - Ch: make(chan *event.Pair, 4096), - Done: make(chan error, 1), - } - numWorkers := runtime.NumCPU() / 4 - if numWorkers == 0 { - numWorkers = 1 - } - for range numWorkers { - f.workers = append(f.workers, newWorker()) - } - return f -} - -func (f IorDataCollector) Start(ctx context.Context) { - go func() { - defer close(f.Done) - var wg sync.WaitGroup - wg.Add(len(f.workers)) - - for i, worker := range f.workers { - fmt.Println("Starting flamegraph worker", i) - go worker.run(ctx, &wg, f.Ch) - } - wg.Wait() - - iod := f.workers[0].iod - if len(f.workers) > 1 { - for i, w := range f.workers[1:] { - iod = iod.merge(w.iod) - fmt.Println("Worker", i+1, "merged") - } - } - if err := iod.serializeToFile(f.flamegraphName); err != nil { - f.Done <- err - return - } - f.Done <- nil - }() -} diff --git a/internal/flamegraph/layout.go b/internal/flamegraph/layout.go deleted file mode 100644 index c319800..0000000 --- a/internal/flamegraph/layout.go +++ /dev/null @@ -1,78 +0,0 @@ -package flamegraph - -import "fmt" - -// FrameLayout captures renderer-agnostic flamegraph geometry for a single frame. -// -// The layout is reusable by non-SVG renderers (for example SDL or WASM UIs) so -// they can render the same hierarchy without depending on SVG internals. -type FrameLayout struct { - Name string - Title string - Fill string - X float64 - Y float64 - Width float64 - Height float64 - Depth int - Total uint64 - Percent float64 -} - -func sanitizeSVGConfig(cfg SVGConfig) SVGConfig { - if cfg.Width <= 0 || cfg.FrameHeight <= 0 || cfg.FontSize <= 0 || cfg.MinWidthPx <= 0 { - return defaultSVGConfig() - } - if cfg.Title == "" { - cfg.Title = defaultSVGConfig().Title - } - return cfg -} - -func canvasHeightFor(cfg SVGConfig, t *trie) int { - return cfg.FrameHeight*(t.maxDepth+1) + 80 -} - -// BuildFrameLayout builds renderer-agnostic frame coordinates from a flamegraph trie. -func BuildFrameLayout(t *trie, cfg SVGConfig) []FrameLayout { - if t == nil || t.root == nil || t.root.total == 0 { - return nil - } - cfg = sanitizeSVGConfig(cfg) - canvasHeight := canvasHeightFor(cfg, t) - out := make([]FrameLayout, 0, len(t.root.children)) - collectFrameLayout(&out, t.root, t.root.total, cfg, 0, 0, canvasHeight, true) - return out -} - -func collectFrameLayout(out *[]FrameLayout, node *trieNode, rootTotal uint64, - cfg SVGConfig, x float64, depth int, canvasHeight int, isRoot bool) { - - if !isRoot { - w := float64(cfg.Width) * (float64(node.total) / float64(rootTotal)) - if w < cfg.MinWidthPx { - return - } - y := float64(canvasHeight - (depth+1)*cfg.FrameHeight) - pct := 100 * float64(node.total) / float64(rootTotal) - *out = append(*out, FrameLayout{ - Name: node.name, - Title: fmt.Sprintf("%s (%d, %.2f%%)", node.name, node.total, pct), - Fill: frameColor(node.name), - X: x, - Y: y, - Width: w, - Height: float64(cfg.FrameHeight - 1), - Depth: depth, - Total: node.total, - Percent: pct, - }) - } - - cursor := x - for _, child := range node.children { - cw := float64(cfg.Width) * (float64(child.total) / float64(rootTotal)) - collectFrameLayout(out, child, rootTotal, cfg, cursor, depth+1, canvasHeight, false) - cursor += cw - } -} diff --git a/internal/flamegraph/layout_test.go b/internal/flamegraph/layout_test.go deleted file mode 100644 index 8fa7398..0000000 --- a/internal/flamegraph/layout_test.go +++ /dev/null @@ -1,77 +0,0 @@ -package flamegraph - -import ( - "math" - "testing" -) - -func almostEqual(a, b float64) bool { - return math.Abs(a-b) < 1e-6 -} - -func TestBuildFrameLayoutBasicGeometry(t *testing.T) { - tr := newTrie() - tr.add([]string{"A"}, 4) - tr.add([]string{"B"}, 1) - tr.computeTotals() - - cfg := defaultSVGConfig() - cfg.Width = 100 - cfg.FrameHeight = 10 - cfg.FontSize = 10 - cfg.MinWidthPx = 1 - - frames := BuildFrameLayout(tr, cfg) - if len(frames) != 2 { - t.Fatalf("frames len = %d, want 2", len(frames)) - } - - a := frames[0] - if a.Name != "A" { - t.Fatalf("first frame name = %q, want %q", a.Name, "A") - } - if !almostEqual(a.X, 0) { - t.Fatalf("A x = %f, want 0", a.X) - } - if !almostEqual(a.Width, 80) { - t.Fatalf("A width = %f, want 80", a.Width) - } - if !almostEqual(a.Percent, 80) { - t.Fatalf("A percent = %f, want 80", a.Percent) - } - if a.Depth != 1 { - t.Fatalf("A depth = %d, want 1", a.Depth) - } - - b := frames[1] - if b.Name != "B" { - t.Fatalf("second frame name = %q, want %q", b.Name, "B") - } - if !almostEqual(b.X, 80) { - t.Fatalf("B x = %f, want 80", b.X) - } - if !almostEqual(b.Width, 20) { - t.Fatalf("B width = %f, want 20", b.Width) - } -} - -func TestBuildFrameLayoutSkipsFramesBelowMinWidth(t *testing.T) { - tr := newTrie() - tr.add([]string{"A"}, 999) - tr.add([]string{"B"}, 1) - tr.computeTotals() - - cfg := defaultSVGConfig() - cfg.Width = 100 - cfg.FrameHeight = 10 - cfg.FontSize = 10 - cfg.MinWidthPx = 1 - - frames := BuildFrameLayout(tr, cfg) - if len(frames) != 1 { - t.Fatalf("frames len = %d, want 1", len(frames)) - } - if frames[0].Name != "A" { - t.Fatalf("remaining frame name = %q, want %q", frames[0].Name, "A") - } -} diff --git a/internal/flamegraph/livehtml.go b/internal/flamegraph/livehtml.go deleted file mode 100644 index 71b955e..0000000 --- a/internal/flamegraph/livehtml.go +++ /dev/null @@ -1,842 +0,0 @@ -package flamegraph - -const liveHTML = ` - - - - - I/O Flame Graph (Live) - - - -
- - - - - - - - LIVE -
- - - - - - -` diff --git a/internal/flamegraph/livehtml_browser_test.go b/internal/flamegraph/livehtml_browser_test.go deleted file mode 100644 index 10252a9..0000000 --- a/internal/flamegraph/livehtml_browser_test.go +++ /dev/null @@ -1,314 +0,0 @@ -package flamegraph - -import ( - "encoding/json" - "fmt" - "os" - "os/exec" - "strings" - "testing" -) - -type jsFrame struct { - Name string `json:"name"` - X float64 `json:"x"` - Y float64 `json:"y"` - W float64 `json:"w"` - H float64 `json:"h"` - Depth int `json:"depth"` -} - -type liveJSResult struct { - Colors map[string]string `json:"colors"` - KnownFrames []jsFrame `json:"knownFrames"` - SVGHTML string `json:"svgHTML"` - ViewBox string `json:"viewBox"` - TallViewBox string `json:"tallViewBox"` - TallHeight string `json:"tallHeight"` - PrunedMaxEnd float64 `json:"prunedMaxEnd"` - SingleCount int