summaryrefslogtreecommitdiff
path: root/internal/flamegraph
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-03-06 13:36:51 +0200
committerPaul Buetow <paul@buetow.org>2026-03-06 13:36:51 +0200
commitef12ce837176bd21deb455eb50a6c839af02b510 (patch)
treec262ceeda0b419236a4b0b1826df8eb5e418b852 /internal/flamegraph
parent10c5d48413afaef88626419d8c4bf9fbf6f1c902 (diff)
Add live flamegraph test modes and dynamic synthetic live feed
Diffstat (limited to 'internal/flamegraph')
-rw-r--r--internal/flamegraph/livetrie.go8
-rw-r--r--internal/flamegraph/livetrie_test.go64
-rw-r--r--internal/flamegraph/testfixture.go120
3 files changed, 190 insertions, 2 deletions
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
+}