Files
patterm/internal/app/metrics_test.go
Harry Bayliss 1c590f8e32 Concrete perf metrics: live counters in --profile + benchmark suite
Live metrics (--profile):
- New metricsTracker instruments OnPTYOut, viewport renderer,
  stdout writes, libghostty-vt Write/Title CGO calls, sidebar /
  tabbar / status draws (with cache-hit accounting), snapshot
  replays, and the chrome ticker (so we can see ticker fires that
  did nothing).
- Writes metrics.jsonl (one snapshot per second) and metrics.json
  + summary.txt on exit, alongside the existing pprof files.
- All record* methods are nil-safe so disabled paths pay only a
  cheap nil check; counters are atomic so the per-PTY-chunk hot
  path stays lock-free.

Benchmark suite (go test -bench=.):
- Three workload fixtures — plain ASCII, SGR-styled lines, and a
  ratatui-style cursor-shuffling burst — plus a containsOSC
  microbenchmark. Reports ns/op, MB/s, allocs/op, B/op.
- Initial baseline numbers added to TODO under the perf-audit
  section, alongside two new findings (renderer allocs ~1 per 4
  bytes on styled chunks; styled throughput tops out near
  90 MB/s) those benchmarks surfaced.
2026-05-15 13:31:37 +01:00

117 lines
3.3 KiB
Go

package app
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"time"
)
func TestMetricsTrackerDisabledByEmptyDir(t *testing.T) {
m, err := newMetricsTracker("")
if err != nil {
t.Fatalf("newMetricsTracker(\"\") err: %v", err)
}
if m != nil {
t.Fatalf("expected nil tracker for empty dir, got %v", m)
}
}
func TestMetricsTrackerRecordsAndWrites(t *testing.T) {
dir := t.TempDir()
m, err := newMetricsTracker(dir)
if err != nil {
t.Fatalf("newMetricsTracker: %v", err)
}
if m == nil {
t.Fatal("expected non-nil tracker")
}
m.recordPTYOut(2*time.Millisecond, 1024)
m.recordPTYOut(5*time.Millisecond, 4096)
m.recordRender(800 * time.Microsecond)
m.recordStdout(300*time.Microsecond, 1100)
m.recordEmuWrite(150 * time.Microsecond)
m.recordEmuTitle(0, true)
m.recordEmuTitle(20*time.Microsecond, false)
m.recordSidebar(100*time.Microsecond, true)
m.recordSidebar(900*time.Microsecond, false)
m.recordTabbar(50*time.Microsecond, true)
m.recordStatus(40*time.Microsecond, true)
m.recordSnapshot(2 * time.Millisecond)
m.recordTickerFire(false)
m.recordTickerFire(true)
m.recordPTYOutDrop()
m.close()
// metrics.json should exist and parse, and reflect what we recorded.
raw, err := os.ReadFile(filepath.Join(dir, "metrics.json"))
if err != nil {
t.Fatalf("read metrics.json: %v", err)
}
var snap metricsSnapshot
if err := json.Unmarshal(raw, &snap); err != nil {
t.Fatalf("parse metrics.json: %v", err)
}
if snap.PTYChunks != 2 {
t.Errorf("PTYChunks = %d, want 2", snap.PTYChunks)
}
if snap.PTYBytes != 5120 {
t.Errorf("PTYBytes = %d, want 5120", snap.PTYBytes)
}
if snap.OnPTYOutMaxNs != (5 * time.Millisecond).Nanoseconds() {
t.Errorf("OnPTYOutMaxNs = %d, want %d",
snap.OnPTYOutMaxNs, (5 * time.Millisecond).Nanoseconds())
}
if snap.SidebarDraws != 2 {
t.Errorf("SidebarDraws = %d, want 2", snap.SidebarDraws)
}
if snap.SidebarCacheHits != 1 {
t.Errorf("SidebarCacheHits = %d, want 1", snap.SidebarCacheHits)
}
if snap.SidebarCacheHitRate != 0.5 {
t.Errorf("SidebarCacheHitRate = %v, want 0.5", snap.SidebarCacheHitRate)
}
if snap.EmuTitleCalls != 1 || snap.EmuTitleSkips != 1 {
t.Errorf("emu title accounting: calls=%d skips=%d, want 1/1",
snap.EmuTitleCalls, snap.EmuTitleSkips)
}
if snap.TickerFires != 2 || snap.TickerIdleFires != 1 {
t.Errorf("ticker accounting: fires=%d idle=%d, want 2/1",
snap.TickerFires, snap.TickerIdleFires)
}
if snap.OnPTYOutDrops != 1 {
t.Errorf("OnPTYOutDrops = %d, want 1", snap.OnPTYOutDrops)
}
// summary.txt should also be present and non-empty.
info, err := os.Stat(filepath.Join(dir, "summary.txt"))
if err != nil {
t.Fatalf("stat summary.txt: %v", err)
}
if info.Size() == 0 {
t.Fatal("summary.txt is empty")
}
}
func TestMetricsTrackerNilSafe(t *testing.T) {
// Every record* method must be safe to call on a nil receiver
// because the hot paths use that to avoid an enabled-check.
var m *metricsTracker
m.recordPTYOut(time.Millisecond, 100)
m.recordPTYOutDrop()
m.recordRender(time.Microsecond)
m.recordStdout(time.Microsecond, 50)
m.recordEmuWrite(time.Microsecond)
m.recordEmuTitle(time.Microsecond, false)
m.recordEmuTitle(0, true)
m.recordSidebar(time.Microsecond, true)
m.recordTabbar(time.Microsecond, false)
m.recordStatus(time.Microsecond, true)
m.recordSnapshot(time.Microsecond)
m.recordTickerFire(true)
m.close()
}