summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-05-18 19:13:59 +0300
committerPaul Buetow <paul@buetow.org>2026-05-18 19:13:59 +0300
commit65599ad9b87b1c61cb6d8232200da88952370e96 (patch)
tree862e20468835255ed06544a2df2470678d3b97dc
parenta92cb0283b1ba8735a6697a8f94911397534131f (diff)
t6 add syscall family dashboard aggregation
-rw-r--r--AGENTS.md2
-rw-r--r--Magefile.go4
-rw-r--r--docs/parquet-querying.md1
-rw-r--r--docs/tutorial/tutorial.md9
-rw-r--r--internal/generate/typesgo.go80
-rw-r--r--internal/generate/typesgo_test.go8
-rw-r--r--internal/ior_mode_test.go3
-rw-r--r--internal/parquet/recorder_test.go1
-rw-r--r--internal/parquet/schema.go2
-rw-r--r--internal/statsengine/engine.go18
-rw-r--r--internal/statsengine/engine_test.go51
-rw-r--r--internal/statsengine/family.go121
-rw-r--r--internal/statsengine/snapshot.go70
-rw-r--r--internal/statsengine/snapshot_test.go55
-rw-r--r--internal/streamrow/row.go3
-rw-r--r--internal/streamrow/row_test.go8
-rw-r--r--internal/tui/common/keys.go6
-rw-r--r--internal/tui/dashboard/model.go10
-rw-r--r--internal/tui/dashboard/nonio.go99
-rw-r--r--internal/tui/dashboard/nonio_test.go37
-rw-r--r--internal/tui/dashboard/tabregistry.go22
-rw-r--r--internal/tui/dashboard/tabs.go2
-rw-r--r--internal/tui/dashboard/tabs_test.go14
-rw-r--r--internal/tui/eventstream/export.go3
-rw-r--r--internal/tui/help.go2
-rw-r--r--internal/types/family.go39
-rw-r--r--internal/types/generated_types.go31
27 files changed, 677 insertions, 24 deletions
diff --git a/AGENTS.md b/AGENTS.md
index 1777a41..194d06f 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -74,7 +74,7 @@ Generator source code:
- **TUI trace flow** ingests events into the in-memory stats engine; it does **not** continuously write trace rows to disk.
- **File output in TUI** is explicit export only (`e`), writing `ior-stream-<timestamp>.csv` in the current directory from the current filtered stream snapshot.
- **Export toggle flag**: `-tuiExport=true|false` (default `true`) enables or disables TUI stream CSV export at runtime.
-- **Tab navigation** supports `tab/shift+tab`, numeric keys `1..7`, and directional keys `left/right` and `h/l`.
+- **Tab navigation** supports `tab/shift+tab`, numeric keys `1..8`, and directional keys `left/right` and `h/l`.
- **When export is disabled**, export key hints are hidden from dashboard help and `e` does not open export modal.
- **Fast-refresh cadence**: `-tui-fast-refresh` (default `250ms`) controls the high-frequency tick interval for the flamegraph and stream tabs; set to `0` to disable high-frequency refresh and fall back to the standard dashboard cadence.
diff --git a/Magefile.go b/Magefile.go
index 6cfdab4..8a6770c 100644
--- a/Magefile.go
+++ b/Magefile.go
@@ -1157,10 +1157,10 @@ func runClickHouseQuery(dir, file, sql string) (string, error) {
return strings.TrimSpace(out), nil
}
-// expectedParquetColumns lists the 14 column names that the parquet schema must contain.
+// expectedParquetColumns lists the 15 column names that the parquet schema must contain.
var expectedParquetColumns = []string{
"seq", "time_ns", "gap_ns", "latency_ns", "comm",
- "pid", "tid", "syscall", "fd", "ret",
+ "pid", "tid", "syscall", "family", "fd", "ret",
"bytes", "file", "is_error", "filter_epoch",
}
diff --git a/docs/parquet-querying.md b/docs/parquet-querying.md
index 4c49baf..783c4e0 100644
--- a/docs/parquet-querying.md
+++ b/docs/parquet-querying.md
@@ -76,6 +76,7 @@ comm String
pid UInt32
tid UInt32
syscall String
+family String
fd Int32
ret Int64
bytes UInt64
diff --git a/docs/tutorial/tutorial.md b/docs/tutorial/tutorial.md
index 01201a2..4c556e0 100644
--- a/docs/tutorial/tutorial.md
+++ b/docs/tutorial/tutorial.md
@@ -58,7 +58,7 @@ The same picker can be re-opened later from the dashboard with `p`.
## Touring the dashboard tabs
-The dashboard has seven tabs, addressable by number key. The default landing tab is **Flamegraph**. `tab` / `shift+tab` step forward / back.
+The dashboard has eight tabs, addressable by number key. The default landing tab is **Flamegraph**. `tab` / `shift+tab` step forward / back.
| Key | Tab | What it shows |
|-----|-------------------|--------------------------------------------------------------------------|
@@ -69,6 +69,7 @@ The dashboard has seven tabs, addressable by number key. The default landing tab
| `5` | Processes (`Pro`) | Per-process / per-comm counters |
| `6` | Latency (`Lat`) | Latency + inter-syscall gap histograms |
| `7` | Stream (`Str`) | Live tail of individual traced events |
+| `8` | Non-IO (`NIO`) | Broad non-file syscall family counters |
### 1 · Flamegraph (default landing tab)
@@ -108,10 +109,14 @@ Press `6`. Two histograms: syscall **latency** (how long the syscall ran) and th
### 7 · Stream
-Press `7`. A live tail of every traced event row: comm, PID, TID, syscall, file, FD, return value, bytes, latency, gap. This is the workhorse view; the next section explores it in depth.
+Press `7`. A live tail of every traced event row: comm, PID, TID, syscall, family, file, FD, return value, bytes, latency, gap. This is the workhorse view; the next section explores it in depth.
![Stream tab live-tailing rows](./assets/07-stream-live.gif)
+### 8 · Non-IO
+
+Press `8`. This groups traced non-file syscall activity by broad family: network, memory, signals, scheduler, IPC, time, process, security, polling, AIO, and miscellaneous.
+
## Mastering the Stream tab
Stream has two modes: **Live** (rows scroll past) and **Pause** (`space` toggles). Almost everything interesting happens in pause mode.
diff --git a/internal/generate/typesgo.go b/internal/generate/typesgo.go
index 9f91981..d668ba3 100644
--- a/internal/generate/typesgo.go
+++ b/internal/generate/typesgo.go
@@ -149,6 +149,7 @@ func parseMember(line string) (CMember, bool) {
func writeTypeDefsAndMaps(b *strings.Builder, constants []CConstant) {
b.WriteString("type EventType uint32\n")
b.WriteString("type TraceId uint32\n\n")
+ writeSyscallFamilyDefs(b)
var sysConstants []CConstant
for _, c := range constants {
@@ -166,12 +167,36 @@ func writeTypeDefsAndMaps(b *strings.Builder, constants []CConstant) {
s = strings.TrimPrefix(s, "SYS_EXIT_")
return strings.ToLower(s)
})
+ writeTraceIdFamilyMap(b, sysConstants)
writeTraceIdStringMethod(b)
writeTraceIdNameMethod(b)
+ writeTraceIdFamilyMethod(b)
b.WriteString("\n")
}
+func writeSyscallFamilyDefs(b *strings.Builder) {
+ b.WriteString(`// SyscallFamily is the broad runtime grouping for a syscall tracepoint.
+type SyscallFamily string
+
+const (
+ FamilyNetwork SyscallFamily = "Network"
+ FamilyMemory SyscallFamily = "Memory"
+ FamilySignals SyscallFamily = "Signals"
+ FamilySched SyscallFamily = "Sched"
+ FamilyIPC SyscallFamily = "IPC"
+ FamilyTime SyscallFamily = "Time"
+ FamilyProcess SyscallFamily = "Process"
+ FamilySecurity SyscallFamily = "Security"
+ FamilyFS SyscallFamily = "FS"
+ FamilyPolling SyscallFamily = "Polling"
+ FamilyAIO SyscallFamily = "AIO"
+ FamilyMisc SyscallFamily = "Misc"
+)
+
+`)
+}
+
func writeTraceIdMap(b *strings.Builder, mapName string, constants []CConstant, transform func(string) string) {
fmt.Fprintf(b, "var %s = map[TraceId]string{\n\t", mapName)
entries := make([]string, 0, len(constants))
@@ -182,6 +207,48 @@ func writeTraceIdMap(b *strings.Builder, mapName string, constants []CConstant,
b.WriteString(",\n}\n\n")
}
+func writeTraceIdFamilyMap(b *strings.Builder, constants []CConstant) {
+ b.WriteString("var traceId2Family = map[TraceId]SyscallFamily{\n\t")
+ entries := make([]string, 0, len(constants))
+ for _, c := range constants {
+ tracepoint := strings.ToLower(c.Name)
+ tracepoint = strings.TrimPrefix(tracepoint, "sys_")
+ family := ClassifySyscallFamily("sys_" + tracepoint)
+ entries = append(entries, fmt.Sprintf("%s: %s", c.Value, syscallFamilyConstName(family)))
+ }
+ b.WriteString(strings.Join(entries, ", "))
+ b.WriteString(",\n}\n\n")
+}
+
+func syscallFamilyConstName(family SyscallFamily) string {
+ switch family {
+ case FamilyNetwork:
+ return "FamilyNetwork"
+ case FamilyMemory:
+ return "FamilyMemory"
+ case FamilySignals:
+ return "FamilySignals"
+ case FamilySched:
+ return "FamilySched"
+ case FamilyIPC:
+ return "FamilyIPC"
+ case FamilyTime:
+ return "FamilyTime"
+ case FamilyProcess:
+ return "FamilyProcess"
+ case FamilySecurity:
+ return "FamilySecurity"
+ case FamilyFS:
+ return "FamilyFS"
+ case FamilyPolling:
+ return "FamilyPolling"
+ case FamilyAIO:
+ return "FamilyAIO"
+ default:
+ return "FamilyMisc"
+ }
+}
+
func writeTraceIdStringMethod(b *strings.Builder) {
b.WriteString(`func (s TraceId) String() string {
str, ok := traceId2String[s]
@@ -206,6 +273,19 @@ func writeTraceIdNameMethod(b *strings.Builder) {
`)
}
+func writeTraceIdFamilyMethod(b *strings.Builder) {
+ b.WriteString(`// Family returns the broad syscall family for this tracepoint.
+func (s TraceId) Family() SyscallFamily {
+ family, ok := traceId2Family[s]
+ if !ok {
+ return FamilyMisc
+ }
+ return family
+}
+
+`)
+}
+
func writeGoStruct(b *strings.Builder, s CStruct) {
goName := snakeToCamel(s.Name)
selfRef := strings.ToLower(goName[:1])
diff --git a/internal/generate/typesgo_test.go b/internal/generate/typesgo_test.go
index 76c0521..fcbabec 100644
--- a/internal/generate/typesgo_test.go
+++ b/internal/generate/typesgo_test.go
@@ -47,6 +47,8 @@ struct fd_event {
const testDefines = `#define SYS_ENTER_OPENAT 784
#define SYS_EXIT_OPENAT 783
+#define SYS_ENTER_EPOLL_WAIT 782
+#define SYS_EXIT_EPOLL_WAIT 781
`
func TestParseCTypesInput(t *testing.T) {
@@ -72,7 +74,7 @@ func TestParseCTypesInput(t *testing.T) {
}
// Check constants
- expectedConsts := 8 // MAX_FILENAME_LENGTH, MAX_PROGNAME_LENGTH, 2 event types, 2 classified, 2 SYS_
+ expectedConsts := 10 // MAX_FILENAME_LENGTH, MAX_PROGNAME_LENGTH, 2 event types, 2 classified, 4 SYS_
if len(constants) != expectedConsts {
t.Errorf("constants = %d, want %d", len(constants), expectedConsts)
}
@@ -232,6 +234,9 @@ func TestGenerateTypesGoTraceIdMaps(t *testing.T) {
requireContains(t, output, "var traceId2Name = map[TraceId]string{")
requireContains(t, output, `784: "openat"`)
requireContains(t, output, `783: "openat"`)
+ requireContains(t, output, "var traceId2Family = map[TraceId]SyscallFamily{")
+ requireContains(t, output, `784: FamilyFS`)
+ requireContains(t, output, `782: FamilyPolling`)
}
func TestGenerateTypesGoTraceIdMethods(t *testing.T) {
@@ -244,6 +249,7 @@ func TestGenerateTypesGoTraceIdMethods(t *testing.T) {
requireContains(t, output, "func (s TraceId) String() string")
requireContains(t, output, "func (s TraceId) Name() string")
+ requireContains(t, output, "func (s TraceId) Family() SyscallFamily")
requireContains(t, output, `return fmt.Sprintf("unknown_trace_id_%d", s)`)
}
diff --git a/internal/ior_mode_test.go b/internal/ior_mode_test.go
index a7c3bee..526b9af 100644
--- a/internal/ior_mode_test.go
+++ b/internal/ior_mode_test.go
@@ -730,6 +730,9 @@ func TestHeadlessParquetSinkRecordsRows(t *testing.T) {
if rows[0].Comm != "keep" || rows[1].Syscall != "openat" {
t.Fatalf("unexpected recorded rows: %+v %+v", rows[0], rows[1])
}
+ if rows[0].Family != "FS" || rows[1].Family != "FS" {
+ t.Fatalf("recorded family tags = %q,%q, want FS,FS", rows[0].Family, rows[1].Family)
+ }
}
func TestTuiTraceStarterFromRunTracePersistsRecorderAcrossRestarts(t *testing.T) {
diff --git a/internal/parquet/recorder_test.go b/internal/parquet/recorder_test.go
index 66c22a3..76d70a5 100644
--- a/internal/parquet/recorder_test.go
+++ b/internal/parquet/recorder_test.go
@@ -137,6 +137,7 @@ func testStreamRow(seq uint64, syscall string, isError bool) streamrow.Row {
Seq: seq,
TimeNs: seq * 10,
Syscall: syscall,
+ Family: "FS",
Comm: "ior-test",
PID: 100 + uint32(seq),
TID: 200 + uint32(seq),
diff --git a/internal/parquet/schema.go b/internal/parquet/schema.go
index 62d448b..03937bf 100644
--- a/internal/parquet/schema.go
+++ b/internal/parquet/schema.go
@@ -21,6 +21,7 @@ type Record struct {
PID uint32 `parquet:"pid"`
TID uint32 `parquet:"tid"`
Syscall string `parquet:"syscall"`
+ Family string `parquet:"family"`
FD int32 `parquet:"fd"`
Ret int64 `parquet:"ret"`
Bytes uint64 `parquet:"bytes"`
@@ -62,6 +63,7 @@ func RecordFromStream(row streamrow.Row, filterEpoch uint64) Record {
PID: row.PID,
TID: row.TID,
Syscall: row.Syscall,
+ Family: row.Family,
FD: row.FD,
Ret: row.RetVal,
Bytes: row.Bytes,
diff --git a/internal/statsengine/engine.go b/internal/statsengine/engine.go
index b7d93fa..7d85e96 100644
--- a/internal/statsengine/engine.go
+++ b/internal/statsengine/engine.go
@@ -56,6 +56,7 @@ type Engine struct {
totalGap uint64
syscalls *syscallAccumulator
+ families *familyAccumulator
files *fileRanker
processes *processAccumulator
latencyHist *histogram
@@ -82,6 +83,7 @@ type snapshotInputs struct {
throughputSeries []float64
syscalls []syscallSnapshotInput
+ families []familySnapshotInput
files []fileSnapshotInput
processes []processSnapshotInput
@@ -104,6 +106,7 @@ func newEngineWithClock(topN int, now func() time.Time) *Engine {
startedAt: now(),
topN: topN,
syscalls: newSyscallAccumulator(),
+ families: newFamilyAccumulator(),
files: newFileRankerWithConfig(topN),
processes: newProcessAccumulatorWithConfig(topN),
latencyHist: newHistogram(),
@@ -132,6 +135,7 @@ func (e *Engine) Reset() {
e.totalLatency = 0
e.totalGap = 0
e.syscalls = newSyscallAccumulator()
+ e.families = newFamilyAccumulator()
e.files = newFileRankerWithConfig(e.topN)
e.processes = newProcessAccumulatorWithConfig(e.topN)
e.latencyHist = newHistogram()
@@ -158,6 +162,7 @@ func (e *Engine) Ingest(pair *event.Pair) {
e.updateErrorAndByteClasses(pair)
e.syscalls.Add(pair)
+ e.families.Add(pair)
e.files.Add(pair)
e.processes.Add(pair)
e.latencyHist.Increment(pair.Duration)
@@ -190,6 +195,7 @@ func (e *Engine) updateErrorAndByteClasses(pair *event.Pair) {
// subSnapshots holds the concurrently built per-category snapshot slices.
type subSnapshots struct {
syscalls []SyscallSnapshot
+ families []FamilySnapshot
files []FileSnapshot
processes []ProcessSnapshot
latencyHist HistogramSnapshot
@@ -216,6 +222,7 @@ func (e *Engine) captureSnapshotInputs() snapshotInputs {
gapSeries: e.gapSeries.Values(),
throughputSeries: e.throughputSeries.Values(),
syscalls: e.syscalls.snapshotInputs(),
+ families: e.families.snapshotInputs(),
files: e.files.snapshotInputs(),
processes: e.processes.snapshotInputs(),
latencyHist: e.latencyHist.snapshotInputs(),
@@ -223,7 +230,7 @@ func (e *Engine) captureSnapshotInputs() snapshotInputs {
}
}
-// buildSubSnapshots runs all five per-category snapshot builders concurrently
+// buildSubSnapshots runs all per-category snapshot builders concurrently
// using errgroup so that any error from a sub-builder is captured and returned
// to the caller instead of being silently dropped.
func buildSubSnapshots(in snapshotInputs, elapsed time.Duration) (subSnapshots, error) {
@@ -239,6 +246,11 @@ func buildSubSnapshots(in snapshotInputs, elapsed time.Duration) (subSnapshots,
})
eg.Go(func() error {
var err error
+ ss.families, err = buildFamilySnapshots(in.families, elapsed)
+ return err
+ })
+ eg.Go(func() error {
+ var err error
ss.files, err = buildFileSnapshots(in.files)
return err
})
@@ -303,9 +315,9 @@ func (e *Engine) Snapshot() (*Snapshot, error) {
return nil, err
}
- snap := NewSnapshot(
+ snap := NewSnapshotWithFamilies(
in.latencySeries, in.gapSeries, in.throughputSeries,
- ss.syscalls, ss.files, ss.processes,
+ ss.syscalls, ss.families, ss.files, ss.processes,
ss.latencyHist, ss.gapHist,
)
populateSnapshotFields(&snap, in, elapsed)
diff --git a/internal/statsengine/engine_test.go b/internal/statsengine/engine_test.go
index f714844..9543405 100644
--- a/internal/statsengine/engine_test.go
+++ b/internal/statsengine/engine_test.go
@@ -67,6 +67,13 @@ func TestEngineIngestAndSnapshotIntegration(t *testing.T) {
if len(snap.Syscalls()) != 3 {
t.Fatalf("expected 3 syscall rows, got %d", len(snap.Syscalls()))
}
+ families := familyRowsByName(snap.Families())
+ if len(families) != 1 {
+ t.Fatalf("expected 1 family row, got %d", len(families))
+ }
+ if fs := families["FS"]; fs.Count != 3 || fs.Errors != 1 || fs.Bytes != 170 {
+ t.Fatalf("FS family = %+v, want count=3 errors=1 bytes=170", fs)
+ }
if len(snap.Files()) != 2 {
t.Fatalf("expected top 2 files due to topN=2, got %d", len(snap.Files()))
}
@@ -78,6 +85,50 @@ func TestEngineIngestAndSnapshotIntegration(t *testing.T) {
}
}
+func TestEngineAggregatesSyscallFamilies(t *testing.T) {
+ clock := &fakeClock{now: time.Unix(3000, 0)}
+ engine := newEngineWithClock(10, clock.Now)
+
+ engine.Ingest(newEnginePair(types.SYS_ENTER_EPOLL_WAIT, 0, types.UNCLASSIFIED, "poller", 1, "", 0, 100, 1))
+ engine.Ingest(newEnginePair(types.SYS_ENTER_POLL, -1, types.UNCLASSIFIED, "poller", 1, "", 0, 300, 2))
+ engine.Ingest(newEnginePair(types.SYS_ENTER_GETPID, 0, types.UNCLASSIFIED, "proc", 2, "", 0, 50, 3))
+ engine.Ingest(newEnginePair(types.SYS_ENTER_READ, 4, types.READ_CLASSIFIED, "reader", 3, "/tmp/a", 4, 25, 4))
+ clock.Advance(time.Second)
+
+ snap, err := engine.Snapshot()
+ if err != nil {
+ t.Fatalf("Snapshot() error = %v", err)
+ }
+
+ families := familyRowsByName(snap.Families())
+ polling := families["Polling"]
+ if polling.Count != 2 || polling.Errors != 1 || polling.LatencyMeanNs != 200 {
+ t.Fatalf("Polling family = %+v, want count=2 errors=1 mean=200ns", polling)
+ }
+ if families["Process"].Count != 1 {
+ t.Fatalf("Process family = %+v, want count=1", families["Process"])
+ }
+ if families["FS"].Count != 1 || families["FS"].Bytes != 4 {
+ t.Fatalf("FS family = %+v, want count=1 bytes=4", families["FS"])
+ }
+
+ nonIO := familyRowsByName(snap.NonIOFamilies())
+ if _, ok := nonIO["FS"]; ok {
+ t.Fatalf("NonIOFamilies should not include FS: %+v", nonIO["FS"])
+ }
+ if nonIO["Polling"].Count != 2 || nonIO["Process"].Count != 1 {
+ t.Fatalf("NonIOFamilies missing expected rows: %+v", nonIO)
+ }
+}
+
+func familyRowsByName(rows []FamilySnapshot) map[string]FamilySnapshot {
+ result := make(map[string]FamilySnapshot, len(rows))
+ for _, row := range rows {
+ result[row.Name] = row
+ }
+ return result
+}
+
func TestEngineSnapshotWithNoEvents(t *testing.T) {
clock := &fakeClock{now: time.Unix(2000, 0)}
engine := newEngineWithClock(10, clock.Now)
diff --git a/internal/statsengine/family.go b/internal/statsengine/family.go
new file mode 100644
index 0000000..3206d57
--- /dev/null
+++ b/internal/statsengine/family.go
@@ -0,0 +1,121 @@
+package statsengine
+
+import (
+ "cmp"
+ "slices"
+ "time"
+
+ "ior/internal/event"
+ "ior/internal/types"
+)
+
+type familyAccumulator struct {
+ byFamily map[types.SyscallFamily]*familyStats
+}
+
+type familyStats struct {
+ family types.SyscallFamily
+ count uint64
+ errorCount uint64
+ totalBytes uint64
+ totalLatency uint64
+ minLatency uint64
+ maxLatency uint64
+}
+
+type familySnapshotInput struct {
+ family types.SyscallFamily
+ count uint64
+ errorCount uint64
+ totalBytes uint64
+ totalLatency uint64
+ minLatency uint64
+ maxLatency uint64
+}
+
+func newFamilyAccumulator() *familyAccumulator {
+ return &familyAccumulator{byFamily: make(map[types.SyscallFamily]*familyStats)}
+}
+
+func (a *familyAccumulator) Add(pair *event.Pair) {
+ if a == nil || pair == nil || pair.EnterEv == nil {
+ return
+ }
+
+ family := pair.EnterEv.GetTraceId().Family()
+ stats := a.byFamily[family]
+ if stats == nil {
+ stats = &familyStats{family: family}
+ a.byFamily[family] = stats
+ }
+
+ stats.count++
+ stats.totalBytes += pair.Bytes
+ stats.totalLatency += pair.Duration
+ stats.updateMinMax(pair.Duration)
+
+ if retEv, ok := pair.ExitEv.(*types.RetEvent); ok && retEv.Ret < 0 {
+ stats.errorCount++
+ }
+}
+
+func (a *familyAccumulator) snapshotInputs() []familySnapshotInput {
+ if a == nil {
+ return nil
+ }
+
+ inputs := make([]familySnapshotInput, 0, len(a.byFamily))
+ for _, stats := range a.byFamily {
+ inputs = append(inputs, familySnapshotInput{
+ family: stats.family,
+ count: stats.count,
+ errorCount: stats.errorCount,
+ totalBytes: stats.totalBytes,
+ totalLatency: stats.totalLatency,
+ minLatency: stats.minLatency,
+ maxLatency: stats.maxLatency,
+ })
+ }
+ return inputs
+}
+
+func buildFamilySnapshots(inputs []familySnapshotInput, elapsed time.Duration) ([]FamilySnapshot, error) {
+ rateDiv := elapsed.Seconds()
+ result := make([]FamilySnapshot, 0, len(inputs))
+ for _, in := range inputs {
+ result = append(result, in.toSnapshot(rateDiv))
+ }
+ slices.SortFunc(result, compareFamilyDefault)
+ return result, nil
+}
+
+func (s *familyStats) updateMinMax(duration uint64) {
+ if s.count == 1 || duration < s.minLatency {
+ s.minLatency = duration
+ }
+ if duration > s.maxLatency {
+ s.maxLatency = duration
+ }
+}
+
+func (s familySnapshotInput) toSnapshot(rateDiv float64) FamilySnapshot {
+ return FamilySnapshot{
+ Family: s.family,
+ Name: string(s.family),
+ 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,
+ }
+}
+
+func compareFamilyDefault(left, right FamilySnapshot) int {
+ if left.Count != right.Count {
+ return cmp.Compare(right.Count, left.Count)
+ }
+ return cmp.Compare(types.SyscallFamilyRank(left.Family), types.SyscallFamilyRank(right.Family))
+}
diff --git a/internal/statsengine/snapshot.go b/internal/statsengine/snapshot.go
index 7a95ab8..859cd2e 100644
--- a/internal/statsengine/snapshot.go
+++ b/internal/statsengine/snapshot.go
@@ -51,6 +51,7 @@ type Snapshot struct {
throughputSeriesB []float64
syscalls []SyscallSnapshot
+ families []FamilySnapshot
files []FileSnapshot
processes []ProcessSnapshot
@@ -77,6 +78,22 @@ type SyscallSnapshot struct {
LatencyP99Ns uint64
}
+// FamilySnapshot is an aggregated syscall-family row.
+type FamilySnapshot struct {
+ Family types.SyscallFamily
+ Name string
+
+ Count uint64
+ RatePerSec float64
+ Errors uint64
+ Bytes uint64
+
+ LatencyMinNs uint64
+ LatencyMaxNs uint64
+ LatencyMeanNs float64
+ TotalLatencyNs uint64
+}
+
// FileSnapshot is an aggregated per-file ranking entry.
type FileSnapshot struct {
Path string
@@ -129,11 +146,31 @@ func NewSnapshot(
latencyHistogram HistogramSnapshot,
gapHistogram HistogramSnapshot,
) Snapshot {
+ return NewSnapshotWithFamilies(
+ latencySeriesNs, gapSeriesNs, throughputSeriesB,
+ syscalls, nil, files, processes,
+ latencyHistogram, gapHistogram,
+ )
+}
+
+// NewSnapshotWithFamilies creates a snapshot including family aggregate rows.
+func NewSnapshotWithFamilies(
+ latencySeriesNs []float64,
+ gapSeriesNs []float64,
+ throughputSeriesB []float64,
+ syscalls []SyscallSnapshot,
+ families []FamilySnapshot,
+ files []FileSnapshot,
+ processes []ProcessSnapshot,
+ latencyHistogram HistogramSnapshot,
+ gapHistogram HistogramSnapshot,
+) Snapshot {
return Snapshot{
latencySeriesNs: slices.Clone(latencySeriesNs),
gapSeriesNs: slices.Clone(gapSeriesNs),
throughputSeriesB: slices.Clone(throughputSeriesB),
syscalls: slices.Clone(syscalls),
+ families: slices.Clone(families),
files: slices.Clone(files),
processes: slices.Clone(processes),
LatencyHistogram: latencyHistogram.Clone(),
@@ -187,6 +224,39 @@ func (s Snapshot) SyscallsCount() int {
return len(s.syscalls)
}
+// Families returns per-syscall-family snapshot rows.
+// Callers must treat returned data as read-only.
+func (s Snapshot) Families() []FamilySnapshot {
+ return s.families
+}
+
+// FamiliesCount returns number of syscall-family rows without cloning backing slices.
+func (s Snapshot) FamiliesCount() int {
+ return len(s.families)
+}
+
+// NonIOFamilies returns family rows outside the file-system/fd-focused family.
+func (s Snapshot) NonIOFamilies() []FamilySnapshot {
+ rows := make([]FamilySnapshot, 0, len(s.families))
+ for _, row := range s.families {
+ if types.IsNonIOSyscallFamily(row.Family) {
+ rows = append(rows, row)
+ }
+ }
+ return rows
+}
+
+// NonIOFamiliesCount returns number of non-FS syscall-family rows.
+func (s Snapshot) NonIOFamiliesCount() int {
+ count := 0
+ for _, row := range s.families {
+ if types.IsNonIOSyscallFamily(row.Family) {
+ count++
+ }
+ }
+ return count
+}
+
// TopNSyscalls returns at most n per-syscall rows in ranking order.
// Callers must treat returned data as read-only.
func (s Snapshot) TopNSyscalls(n int) []SyscallSnapshot {
diff --git a/internal/statsengine/snapshot_test.go b/internal/statsengine/snapshot_test.go
index 277fa0d..9b54409 100644
--- a/internal/statsengine/snapshot_test.go
+++ b/internal/statsengine/snapshot_test.go
@@ -1,22 +1,28 @@
package statsengine
-import "testing"
+import (
+ "testing"
+
+ "ior/internal/types"
+)
func TestNewSnapshotDefensivelyCopiesSlices(t *testing.T) {
latency := []float64{1, 2, 3}
gap := []float64{4, 5, 6}
throughput := []float64{7, 8, 9}
syscalls := []SyscallSnapshot{{Name: "read", Count: 1}}
+ families := []FamilySnapshot{{Family: types.FamilyPolling, Name: "Polling", Count: 3}}
files := []FileSnapshot{{Path: "/tmp/a", Accesses: 2}}
processes := []ProcessSnapshot{{PID: 10, Comm: "cmd"}}
latencyBuckets := []HistogramBucketSnapshot{{Label: "[0,1)", Count: 3}}
gapBuckets := []HistogramBucketSnapshot{{Label: "[1,10)", Count: 4}}
- s := NewSnapshot(
+ s := NewSnapshotWithFamilies(
latency,
gap,
throughput,
syscalls,
+ families,
files,
processes,
NewHistogramSnapshot(3, latencyBuckets),
@@ -27,6 +33,7 @@ func TestNewSnapshotDefensivelyCopiesSlices(t *testing.T) {
gap[0] = 99
throughput[0] = 99
syscalls[0].Name = "write"
+ families[0].Name = "FS"
files[0].Path = "/tmp/b"
processes[0].Comm = "mutated"
latencyBuckets[0].Count = 99
@@ -44,6 +51,9 @@ func TestNewSnapshotDefensivelyCopiesSlices(t *testing.T) {
if got := s.Syscalls()[0].Name; got != "read" {
t.Fatalf("syscalls mutated through input slice: got %q", got)
}
+ if got := s.Families()[0].Name; got != "Polling" {
+ t.Fatalf("families mutated through input slice: got %q", got)
+ }
if got := s.Files()[0].Path; got != "/tmp/a" {
t.Fatalf("files mutated through input slice: got %q", got)
}
@@ -59,11 +69,12 @@ func TestNewSnapshotDefensivelyCopiesSlices(t *testing.T) {
}
func TestSnapshotAccessorsReturnReadOnlyViews(t *testing.T) {
- s := NewSnapshot(
+ s := NewSnapshotWithFamilies(
[]float64{1},
[]float64{2},
[]float64{3},
[]SyscallSnapshot{{Name: "read"}},
+ []FamilySnapshot{{Family: types.FamilyPolling, Name: "Polling"}},
[]FileSnapshot{{Path: "/tmp/a"}},
[]ProcessSnapshot{{Comm: "cmd"}},
NewHistogramSnapshot(1, []HistogramBucketSnapshot{{Label: "a", Count: 1}}),
@@ -82,6 +93,12 @@ func TestSnapshotAccessorsReturnReadOnlyViews(t *testing.T) {
t.Fatalf("expected accessor to return backing slice view, got %q", got)
}
+ families := s.Families()
+ families[0].Name = "Process"
+ if got := s.Families()[0].Name; got != "Process" {
+ t.Fatalf("expected family accessor to return backing slice view, got %q", got)
+ }
+
buckets := s.LatencyHistogram.Buckets()
buckets[0].Count = 99
if got := s.LatencyHistogram.Buckets()[0].Count; got != 99 {
@@ -97,6 +114,9 @@ func TestNilAccessorsRemainNil(t *testing.T) {
if got := s.Syscalls(); got != nil {
t.Fatalf("expected nil syscalls, got %#v", got)
}
+ if got := s.Families(); got != nil {
+ t.Fatalf("expected nil families, got %#v", got)
+ }
h := HistogramSnapshot{}
if got := h.Buckets(); got != nil {
@@ -104,6 +124,35 @@ func TestNilAccessorsRemainNil(t *testing.T) {
}
}
+func TestSnapshotNonIOFamilies(t *testing.T) {
+ s := NewSnapshotWithFamilies(
+ nil,
+ nil,
+ nil,
+ nil,
+ []FamilySnapshot{
+ {Family: types.FamilyFS, Name: "FS"},
+ {Family: types.FamilyPolling, Name: "Polling"},
+ {Family: types.FamilyProcess, Name: "Process"},
+ },
+ nil,
+ nil,
+ HistogramSnapshot{},
+ HistogramSnapshot{},
+ )
+
+ rows := s.NonIOFamilies()
+ if len(rows) != 2 {
+ t.Fatalf("NonIOFamilies len = %d, want 2", len(rows))
+ }
+ if rows[0].Family == types.FamilyFS || rows[1].Family == types.FamilyFS {
+ t.Fatalf("NonIOFamilies included FS: %+v", rows)
+ }
+ if got := s.NonIOFamiliesCount(); got != 2 {
+ t.Fatalf("NonIOFamiliesCount = %d, want 2", got)
+ }
+}
+
func TestTopNAccessors(t *testing.T) {
s := NewSnapshot(
nil,
diff --git a/internal/streamrow/row.go b/internal/streamrow/row.go
index 026aa93..5bccb61 100644
--- a/internal/streamrow/row.go
+++ b/internal/streamrow/row.go
@@ -15,6 +15,7 @@ type Row struct {
Seq uint64
TimeNs uint64
Syscall string
+ Family string
Comm string
PID