cat / 012
clone

a spreadsheet for the terminal: lotus 1-2-3 looks, google sheets keys and formulas, one go binary

Instrument recalc, files, commands, JEV, charts and frames

Dami committed 8124ca1
012 --log events.jsonl (or O12_LOG) records:

- recalc: cells evaluated, stored and volatile, full or incremental,
  through sheet.OnRecalc, so the engine stays free of logging
- import and export (format, bytes, rows, cells), save and open
- every command, and sort, filter, find, replace and fill with sizes
- JEV questions (kind, queue depth, outcome) and chart images
- frames: render time and key press to frame, summarized each second

BenchmarkTelemetry: an arrow key through to its frame on 8192x26 costs
1.049 ms with telemetry off and 1.042 ms on (noise); an edit adds about
0.7 us for its recalc event.
commit
8124ca1acbc8f41cd13327c91754e26858c12d66
parent
595dd8cec9ab097f566a9b8b04887133e4e95dd2
author
Dami <dami.etoile@gmail.com>
19 files changed, +302 -15
Mcmd/012/main.go12++++-
Acmd/012/telemetry.go57++++++++++++++++++++++++++
Minternal/fileio/fileio.go38++++++++++++++++++
Minternal/fileio/stress_test.go4+-
Ainternal/sheet/observe.go38++++++++++++++++++
Minternal/sheet/sheet.go7+++-
Minternal/sheet/stress_test.go4+-
Minternal/stress/gen.go2+-
Minternal/ui/commands.go6+++
Minternal/ui/fill.go5++
Minternal/ui/filter.go4++
Minternal/ui/find.go6+++
Minternal/ui/graphics.go8+++-
Minternal/ui/jev.go15++++++-
Minternal/ui/menu.go21++++++++++
Minternal/ui/model.go6+++
Minternal/ui/panel.go15+++++++
Minternal/ui/sort.go4++
Minternal/ui/stress_test.go65+++++++++++++++++++++++++++++-
M cmd/012/main.go +9 -3
A cmd/012/telemetry.go +57 -0
@@ -0,0 +1,57 @@
1+package main2+3+import (4+ "errors"5+ "log/slog"6+ "runtime/debug"7+ "strings"8+9+ "github.com/FelineStateMachine/012/internal/sheet"10+ "github.com/FelineStateMachine/012/internal/telemetry"11+)12+13+// logFlag takes --log path (or --log=path) out of args. Without it the14+// log file comes from O12_LOG, and telemetry stays off when neither is15+// set.16+func logFlag(args []string) (telemetry.Config, []string, error) {17+ c := telemetry.ConfigFromEnv()18+ var rest []string19+ for i := 0; i < len(args); i++ {20+ switch a := args[i]; {21+ case a == "--log":22+ if i+1 >= len(args) {23+ return c, nil, errors.New("--log needs a file")24+ }25+ c.LogPath = args[i+1]26+ i++27+ case strings.HasPrefix(a, "--log="):28+ c.LogPath = strings.TrimPrefix(a, "--log=")29+ default:30+ rest = append(rest, a)31+ }32+ }33+ return c, rest, nil34+}35+36+// startTelemetry opens the log and reports every recalculation to it.37+func startTelemetry(c telemetry.Config) (func() error, error) {38+ if info, ok := debug.ReadBuildInfo(); ok {39+ c.Version = info.Main.Version40+ for _, s := range info.Settings {41+ if s.Key == "vcs.revision" {42+ c.Version = s.Value43+ }44+ }45+ }46+ stop, err := telemetry.Setup(c)47+ if err != nil || !telemetry.Enabled() {48+ return stop, err49+ }50+ sheet.OnRecalc = func(i sheet.RecalcInfo) {51+ telemetry.Set("cells", int64(i.Cells))52+ telemetry.Event("recalc", i.Duration,53+ slog.Bool("full", i.Full), slog.Int("evaluated", i.Evaluated), slog.Int("cells", i.Cells),54+ slog.Int("volatile", i.Volatile), slog.Bool("circular", i.Circular))55+ }56+ return stop, nil57+}
M internal/fileio/fileio.go +38 -0
@@ -9,11 +9,14 @@ import (
99 "context"1010 "errors"1111 "fmt"12+ "log/slog"13+ "os"1214 "path/filepath"1315 "strings"1416 "sync/atomic"1517 1618 "github.com/FelineStateMachine/012/internal/sheet"19+ "github.com/FelineStateMachine/012/internal/telemetry"1720 )1821 1922 // Kind is an external file format.
@@ -177,6 +180,19 @@ func Import(ctx context.Context, name string, opt Options) (*Result, error) {
177180 if !ok {178181 return nil, ErrUnsupported179182 }183+ span := telemetry.Start("import", slog.String("format", k.String()))184+ r, err := importKind(ctx, name, k, opt)185+ if err != nil {186+ span.Fail(err)187+ return nil, err188+ }189+ if telemetry.Enabled() {190+ span.End(slog.Int64("bytes", fileSize(name)), slog.Int("rows", r.Rows), slog.Int("cells", r.Sheet.Len()), slog.Int("notes", len(r.Notes)))191+ }192+ return r, nil193+}194+195+func importKind(ctx context.Context, name string, k Kind, opt Options) (*Result, error) {180196 var (181197 r *Result182198 err error
@@ -216,6 +232,19 @@ type ExportResult struct {
216232 217233 // Export writes a snapshot of a sheet to name in format k.218234 func Export(ctx context.Context, name string, k Kind, snap *Snapshot, opt ExportOptions) (*ExportResult, error) {235+ span := telemetry.Start("export", slog.String("format", k.String()), slog.Int("cells", len(snap.Cells)))236+ res, err := exportKind(ctx, name, k, snap, opt)237+ if err != nil {238+ span.Fail(err)239+ return nil, err240+ }241+ if telemetry.Enabled() {242+ span.End(slog.Int("rows", res.Rows), slog.Int64("bytes", fileSize(name)))243+ }244+ return res, nil245+}246+247+func exportKind(ctx context.Context, name string, k Kind, snap *Snapshot, opt ExportOptions) (*ExportResult, error) {219248 switch k {220249 case CSV, TSV:221250 return exportDelimited(name, k, snap)
@@ -226,3 +255,12 @@ func Export(ctx context.Context, name string, k Kind, snap *Snapshot, opt Export
226255 }227256 return nil, fmt.Errorf("can't export %s files", k)228257 }258+259+// fileSize is the size of the file name, for telemetry; -1 if unknown.260+func fileSize(name string) int64 {261+ st, err := os.Stat(name)262+ if err != nil {263+ return -1264+ }265+ return st.Size()266+}
M internal/fileio/stress_test.go +2 -2
A internal/sheet/observe.go +38 -0
M internal/sheet/sheet.go +6 -1
M internal/sheet/stress_test.go +2 -2
M internal/stress/gen.go +1 -1
M internal/ui/commands.go +6 -0
M internal/ui/fill.go +5 -0
M internal/ui/filter.go +4 -0
M internal/ui/find.go +6 -0
M internal/ui/graphics.go +7 -1
M internal/ui/jev.go +13 -2
M internal/ui/menu.go +21 -0
@@ -2,6 +2,7 @@ package ui
22 33 import (44 "fmt"5+ "log/slog"56 "os"67 "path/filepath"78 "slices"
@@ -13,6 +14,7 @@ import (
1314 1415 "github.com/FelineStateMachine/012/internal/fileio"1516 "github.com/FelineStateMachine/012/internal/sheet"17+ "github.com/FelineStateMachine/012/internal/telemetry"1618 )1719 1820 type promptKind int
@@ -268,8 +270,12 @@ type filesMsg []string
268270 // saveCmd writes the worksheet atomically: to a temporary file first, then269271 // renamed over the target.270272 func saveCmd(s *sheet.Sheet, name string) tea.Cmd {273+ span := telemetry.Start("save")271274 var buf strings.Builder272275 err := s.Write(&buf)276+ if telemetry.Enabled() {277+ span.End(slog.Int("cells", s.Len()), slog.Int("bytes", buf.Len()))278+ }273279 return func() tea.Msg {274280 if err != nil {275281 return savedMsg{name, err}
@@ -289,11 +295,26 @@ func loadCmd(name string) tea.Cmd {
289295 return loadedMsg{name: name, err: err}290296 }291297 defer f.Close()298+ span := telemetry.Start("open")292299 s, err := sheet.Read(f)300+ if err != nil {301+ span.Fail(err)302+ } else if telemetry.Enabled() {303+ span.End(slog.Int("cells", s.Len()), slog.Int64("bytes", openSize(f)))304+ }293305 return loadedMsg{name, s, err}294306 }295307 }296308 309+// openSize is the size of an open file, for telemetry; -1 if unknown.310+func openSize(f *os.File) int64 {311+ st, err := f.Stat()312+ if err != nil {313+ return -1314+ }315+ return st.Size()316+}317+297318 func listFilesCmd() tea.Msg {298319 files, _ := filepath.Glob("*" + sheet.FileExt)299320 slices.Sort(files)
M internal/ui/model.go +6 -0
M internal/ui/panel.go +15 -0
M internal/ui/sort.go +4 -0
M internal/ui/stress_test.go +62 -3
@@ -11,6 +11,8 @@ import (
1111 "context"1212 "fmt"1313 "io"14+ "log/slog"15+ "path/filepath"1416 "testing"1517 "time"1618
@@ -18,9 +20,10 @@ import (
1820 typesafe "github.com/FelineStateMachine/typesafe-go"1921 uv "github.com/charmbracelet/ultraviolet"2022 21- "012/internal/jev"22- "012/internal/sheet"23- "012/internal/stress"23+ "github.com/FelineStateMachine/012/internal/jev"24+ "github.com/FelineStateMachine/012/internal/sheet"25+ "github.com/FelineStateMachine/012/internal/stress"26+ "github.com/FelineStateMachine/012/internal/telemetry"2427 )2528 2629 var termSizes = []struct{ w, h int }{{80, 24}, {200, 60}, {400, 120}}
@@ -179,6 +182,62 @@ func BenchmarkKeystroke(b *testing.B) {
179182 }180183 }181184 185+// BenchmarkTelemetry is an arrow key through to its frame and an edit186+// that recalculates, with telemetry off and on (a JSON log in a temp187+// file): what the instrumentation costs.188+func BenchmarkTelemetry(b *testing.B) {189+ s := lazy(func() *sheet.Sheet { return stress.Dense(sheet.MaxRows, 26) })190+ for _, on := range []bool{false, true} {191+ name := "off"192+ if on {193+ name = "on"194+ }195+ b.Run("arrow/"+name, func(b *testing.B) {196+ stop := telemetryFor(b, on)197+ defer stop()198+ m := sized(s(), 200, 60)199+ t := newFakeTerm(200, 60)200+ keys := []string{"down", "up"}201+ i := 0202+ for b.Loop() {203+ m.Update(key(keys[i%2]))204+ t.frame(m)205+ i++206+ }207+ })208+ b.Run("edit/"+name, func(b *testing.B) {209+ stop := telemetryFor(b, on)210+ defer stop()211+ m := sized(s(), 200, 60)212+ keys := []string{"7", "enter", "up"}213+ i := 0214+ for b.Loop() {215+ m.Update(key(keys[i%3]))216+ i++217+ }218+ })219+ }220+}221+222+// telemetryFor turns telemetry on, logging to a temp file, when on is223+// set, the way cmd/012 does.224+func telemetryFor(b *testing.B, on bool) func() error {225+ if !on {226+ return func() error { return nil }227+ }228+ stop, err := telemetry.Setup(telemetry.Config{LogPath: filepath.Join(b.TempDir(), "log.jsonl")})229+ if err != nil {230+ b.Fatal(err)231+ }232+ sheet.OnRecalc = func(i sheet.RecalcInfo) {233+ telemetry.Event("recalc", i.Duration, slog.Int("evaluated", i.Evaluated), slog.Int("cells", i.Cells))234+ }235+ return func() error {236+ sheet.OnRecalc = nil237+ return stop()238+ }239+}240+182241 // fakeJEV answers every question at once, never touching the network.183242 type stressJEV struct{}184243