detached 6d5f7c4

feat(logging): per-run log files and make tty/logs workflow

commit
6d5f7c472e6b3e061f698b549fbc56e51d1e1b14
author
brice · · 2026-09-24 07:22 UTC
parent
b5740ee
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +75 -20
M .gitignore +1 -0
@@ -1,4 +1,5 @@
11 /nefertty
2+/bin/
23 *.test
34 *.out
45 coverage/
@@ -1,4 +1,5 @@
1 /nefertty1 /nefertty
2+/bin/
2 *.test3 *.test
3 *.out4 *.out
4 coverage/5 coverage/
M Makefile +17 -1
@@ -1,4 +1,20 @@
1-.PHONY: build test vet race mocks mocks-check arch check
1+.PHONY: build test vet race mocks mocks-check arch check bin tty logs
2+
3+TTY_TIMEOUT ?= 60s
4+RUNS := $(or $(XDG_STATE_HOME),$(HOME)/.local/state)/nefertty/runs
5+
6+# Build a fresh binary with the git revision embedded.
7+bin:
8+ CGO_ENABLED=0 go build -o bin/nefertty ./cmd/nefertty
9+
10+# Run on the current TTY. Quit: Ctrl+Alt+Backspace, or wait TTY_TIMEOUT.
11+tty: bin
12+ ./bin/nefertty --backend=drm --debug=all --timeout=$(TTY_TIMEOUT); \
13+ echo "exit $$? - log: $(RUNS)/latest.log"
14+
15+# Summarise the last run: lifecycle, warnings and errors.
16+logs:
17+ @jq -r 'select(.level != "debug") | "\(.time) \(.level) [\(.component)] \(.message) \(del(.time,.level,.component,.message) | tostring)"' $(RUNS)/latest.log
218 build:
319 CGO_ENABLED=0 go build ./...
420 test:
@@ -1,4 +1,20 @@
1-.PHONY: build test vet race mocks mocks-check arch check1+.PHONY: build test vet race mocks mocks-check arch check bin tty logs
2+
3+TTY_TIMEOUT ?= 60s
4+RUNS := $(or $(XDG_STATE_HOME),$(HOME)/.local/state)/nefertty/runs
5+
6+# Build a fresh binary with the git revision embedded.
7+bin:
8+ CGO_ENABLED=0 go build -o bin/nefertty ./cmd/nefertty
9+
10+# Run on the current TTY. Quit: Ctrl+Alt+Backspace, or wait TTY_TIMEOUT.
11+tty: bin
12+ ./bin/nefertty --backend=drm --debug=all --timeout=$(TTY_TIMEOUT); \
13+ echo "exit $$? - log: $(RUNS)/latest.log"
14+
15+# Summarise the last run: lifecycle, warnings and errors.
16+logs:
17+ @jq -r 'select(.level != "debug") | "\(.time) \(.level) [\(.component)] \(.message) \(del(.time,.level,.component,.message) | tostring)"' $(RUNS)/latest.log
2 build:18 build:
3 CGO_ENABLED=0 go build ./...19 CGO_ENABLED=0 go build ./...
4 test:20 test:
M cmd/nefertty/main.go +24 -9
@@ -10,6 +10,7 @@ import (
1010 "runtime/debug"
1111 "strings"
1212 "syscall"
13+ "time"
1314
1415 "github.com/bnema/nefertty/internal/adapters/config"
1516 "github.com/bnema/nefertty/internal/app"
@@ -161,15 +162,29 @@ func run() error {
161162 }
162163 defer closeLog()
163164 log := logging.For(ctx, "app")
165+ defer func() {
166+ if p := recover(); p != nil {
167+ log.Error().Interface("panic", p).Str("stack", string(debug.Stack())).Msg("exit")
168+ _ = closeLog()
169+ panic(p)
170+ }
171+ }()
172+ start := time.Now()
173+ log.Info().Strs("args", os.Args[1:]).Str("backend", *backend).Str("tty", os.Getenv("XDG_VTNR")).Str("session_type", os.Getenv("XDG_SESSION_TYPE")).Msg("run")
164174 configLog := logging.For(ctx, "config")
165175 configLog.Info().Str("path", path).Msg("loaded config")
166- if err := app.Run(ctx, app.Options{Backend: *backend, Config: cfg, ConfigPath: path, Timeout: *timeout, NoTerminal: *noTerminal, ScreenshotDir: *screenshot, Script: script}); err != nil {
167- // SIGINT and SIGTERM cancel the context and are clean exits.
168- if ctx.Err() != nil && errors.Is(err, context.Canceled) {
169- return nil
170- }
171- log.Error().Err(err).Msg("application failed")
172- return err
173- }
174- return nil
176+ err = app.Run(ctx, app.Options{Backend: *backend, Config: cfg, ConfigPath: path, Timeout: *timeout, NoTerminal: *noTerminal, ScreenshotDir: *screenshot, Script: script})
177+ // SIGINT and SIGTERM cancel the context and are clean exits.
178+ if err != nil && ctx.Err() != nil && errors.Is(err, context.Canceled) {
179+ err = nil
180+ }
181+ reason := "clean"
182+ switch {
183+ case err != nil:
184+ reason = "error"
185+ case ctx.Err() != nil:
186+ reason = "signal"
187+ }
188+ log.Info().Str("reason", reason).AnErr("error", err).Dur("uptime", time.Since(start)).Msg("exit")
189+ return err
175190 }
@@ -10,6 +10,7 @@ import (
10 "runtime/debug"10 "runtime/debug"
11 "strings"11 "strings"
12 "syscall"12 "syscall"
13+ "time"
13 14
14 "github.com/bnema/nefertty/internal/adapters/config"15 "github.com/bnema/nefertty/internal/adapters/config"
15 "github.com/bnema/nefertty/internal/app"16 "github.com/bnema/nefertty/internal/app"
@@ -161,15 +162,29 @@ func run() error {
161 }162 }
162 defer closeLog()163 defer closeLog()
163 log := logging.For(ctx, "app")164 log := logging.For(ctx, "app")
165+ defer func() {
166+ if p := recover(); p != nil {
167+ log.Error().Interface("panic", p).Str("stack", string(debug.Stack())).Msg("exit")
168+ _ = closeLog()
169+ panic(p)
170+ }
171+ }()
172+ start := time.Now()
173+ log.Info().Strs("args", os.Args[1:]).Str("backend", *backend).Str("tty", os.Getenv("XDG_VTNR")).Str("session_type", os.Getenv("XDG_SESSION_TYPE")).Msg("run")
164 configLog := logging.For(ctx, "config")174 configLog := logging.For(ctx, "config")
165 configLog.Info().Str("path", path).Msg("loaded config")175 configLog.Info().Str("path", path).Msg("loaded config")
166- if err := app.Run(ctx, app.Options{Backend: *backend, Config: cfg, ConfigPath: path, Timeout: *timeout, NoTerminal: *noTerminal, ScreenshotDir: *screenshot, Script: script}); err != nil {176+ err = app.Run(ctx, app.Options{Backend: *backend, Config: cfg, ConfigPath: path, Timeout: *timeout, NoTerminal: *noTerminal, ScreenshotDir: *screenshot, Script: script})
167- // SIGINT and SIGTERM cancel the context and are clean exits.177+ // SIGINT and SIGTERM cancel the context and are clean exits.
168- if ctx.Err() != nil && errors.Is(err, context.Canceled) {178+ if err != nil && ctx.Err() != nil && errors.Is(err, context.Canceled) {
169- return nil179+ err = nil
170- }180+ }
171- log.Error().Err(err).Msg("application failed")181+ reason := "clean"
172- return err182+ switch {
173- }183+ case err != nil:
174- return nil184+ reason = "error"
185+ case ctx.Err() != nil:
186+ reason = "signal"
187+ }
188+ log.Info().Str("reason", reason).AnErr("error", err).Dur("uptime", time.Since(start)).Msg("exit")
189+ return err
175 }190 }
M internal/logging/logging.go +25 -6
@@ -6,7 +6,9 @@ import (
66 "fmt"
77 "os"
88 "path/filepath"
9+ "sort"
910 "strings"
11+ "time"
1012
1113 "github.com/bnema/zerowrap"
1214 "github.com/rs/zerolog"
@@ -40,7 +42,11 @@ func ParseDebug(value string) (map[string]bool, error) {
4042 return selected, nil
4143 }
4244
43-// Open rotates the previous run's log, opens a fresh JSON log and attaches the logger to ctx.
45+// keepRuns is how many per-run log files Open retains.
46+const keepRuns = 20
47+
48+// Open creates runs/<timestamp>.log, points runs/latest.log at it, prunes old runs
49+// and attaches the logger to ctx.
4450 // The caller must invoke close after all logging is complete.
4551 func Open(ctx context.Context, level, debug string) (context.Context, func() error, error) {
4652 selected, err := ParseDebug(debug)
@@ -55,18 +61,22 @@ func Open(ctx context.Context, level, debug string) (context.Context, func() err
5561 }
5662 state = filepath.Join(home, ".local", "state")
5763 }
58- dir := filepath.Join(state, "nefertty")
64+ dir := filepath.Join(state, "nefertty", "runs")
5965 if err := os.MkdirAll(dir, 0o700); err != nil {
6066 return nil, nil, err
6167 }
62- path := filepath.Join(dir, "nefertty.log")
63- if err := os.Rename(path, path+".1"); err != nil && !os.IsNotExist(err) {
68+ name := time.Now().Format("20060102-150405.000") + ".log"
69+ file, err := os.OpenFile(filepath.Join(dir, name), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
70+ if err != nil {
6471 return nil, nil, err
6572 }
66- file, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600)
67- if err != nil {
73+ latest := filepath.Join(dir, "latest.log")
74+ _ = os.Remove(latest)
75+ if err := os.Symlink(name, latest); err != nil {
76+ _ = file.Close()
6877 return nil, nil, err
6978 }
79+ pruneRuns(dir)
7080 var output = zerowrap.Config{Level: level, Format: "json", Output: file}
7181 if term.IsTerminal(int(os.Stderr.Fd())) {
7282 output.Output = &consoleAndFile{file: file}
@@ -77,6 +87,15 @@ func Open(ctx context.Context, level, debug string) (context.Context, func() err
7787 return ctx, file.Close, nil
7888 }
7989
90+func pruneRuns(dir string) {
91+ runs, _ := filepath.Glob(filepath.Join(dir, "2*.log"))
92+ sort.Strings(runs)
93+ for len(runs) > keepRuns {
94+ _ = os.Remove(runs[0])
95+ runs = runs[1:]
96+ }
97+}
98+
8099 type consoleAndFile struct{ file *os.File }
81100
82101 func (w *consoleAndFile) Write(p []byte) (int, error) {
@@ -6,7 +6,9 @@ import (
6 "fmt"6 "fmt"
7 "os"7 "os"
8 "path/filepath"8 "path/filepath"
9+ "sort"
9 "strings"10 "strings"
11+ "time"
10 12
11 "github.com/bnema/zerowrap"13 "github.com/bnema/zerowrap"
12 "github.com/rs/zerolog"14 "github.com/rs/zerolog"
@@ -40,7 +42,11 @@ func ParseDebug(value string) (map[string]bool, error) {
40 return selected, nil42 return selected, nil
41 }43 }
42 44
43-// Open rotates the previous run's log, opens a fresh JSON log and attaches the logger to ctx.45+// keepRuns is how many per-run log files Open retains.
46+const keepRuns = 20
47+
48+// Open creates runs/<timestamp>.log, points runs/latest.log at it, prunes old runs
49+// and attaches the logger to ctx.
44 // The caller must invoke close after all logging is complete.50 // The caller must invoke close after all logging is complete.
45 func Open(ctx context.Context, level, debug string) (context.Context, func() error, error) {51 func Open(ctx context.Context, level, debug string) (context.Context, func() error, error) {
46 selected, err := ParseDebug(debug)52 selected, err := ParseDebug(debug)
@@ -55,18 +61,22 @@ func Open(ctx context.Context, level, debug string) (context.Context, func() err
55 }61 }
56 state = filepath.Join(home, ".local", "state")62 state = filepath.Join(home, ".local", "state")
57 }63 }
58- dir := filepath.Join(state, "nefertty")64+ dir := filepath.Join(state, "nefertty", "runs")
59 if err := os.MkdirAll(dir, 0o700); err != nil {65 if err := os.MkdirAll(dir, 0o700); err != nil {
60 return nil, nil, err66 return nil, nil, err
61 }67 }
62- path := filepath.Join(dir, "nefertty.log")68+ name := time.Now().Format("20060102-150405.000") + ".log"
63- if err := os.Rename(path, path+".1"); err != nil && !os.IsNotExist(err) {69+ file, err := os.OpenFile(filepath.Join(dir, name), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
70+ if err != nil {
64 return nil, nil, err71 return nil, nil, err
65 }72 }
66- file, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600)73+ latest := filepath.Join(dir, "latest.log")
67- if err != nil {74+ _ = os.Remove(latest)
75+ if err := os.Symlink(name, latest); err != nil {
76+ _ = file.Close()
68 return nil, nil, err77 return nil, nil, err
69 }78 }
79+ pruneRuns(dir)
70 var output = zerowrap.Config{Level: level, Format: "json", Output: file}80 var output = zerowrap.Config{Level: level, Format: "json", Output: file}
71 if term.IsTerminal(int(os.Stderr.Fd())) {81 if term.IsTerminal(int(os.Stderr.Fd())) {
72 output.Output = &consoleAndFile{file: file}82 output.Output = &consoleAndFile{file: file}
@@ -77,6 +87,15 @@ func Open(ctx context.Context, level, debug string) (context.Context, func() err
77 return ctx, file.Close, nil87 return ctx, file.Close, nil
78 }88 }
79 89
90+func pruneRuns(dir string) {
91+ runs, _ := filepath.Glob(filepath.Join(dir, "2*.log"))
92+ sort.Strings(runs)
93+ for len(runs) > keepRuns {
94+ _ = os.Remove(runs[0])
95+ runs = runs[1:]
96+ }
97+}
98+
80 type consoleAndFile struct{ file *os.File }99 type consoleAndFile struct{ file *os.File }
81 100
82 func (w *consoleAndFile) Write(p []byte) (int, error) {101 func (w *consoleAndFile) Write(p []byte) (int, error) {
M internal/logging/logging_test.go +8 -4
@@ -7,6 +7,7 @@ import (
77 "os"
88 "path/filepath"
99 "testing"
10+ "time"
1011 )
1112
1213 func TestParseDebug(t *testing.T) {
@@ -32,8 +33,10 @@ func TestParseDebug(t *testing.T) {
3233
3334 func TestComponentLevelsAndRotation(t *testing.T) {
3435 t.Setenv("XDG_STATE_HOME", t.TempDir())
35- path := filepath.Join(os.Getenv("XDG_STATE_HOME"), "nefertty", "nefertty.log")
36- for run := 0; run < 2; run++ {
36+ dir := filepath.Join(os.Getenv("XDG_STATE_HOME"), "nefertty", "runs")
37+ path := filepath.Join(dir, "latest.log")
38+ for run := 0; run < keepRuns+2; run++ {
39+ time.Sleep(2 * time.Millisecond) // distinct millisecond timestamps
3740 ctx, closeLog, err := Open(context.Background(), "info", "core")
3841 if err != nil {
3942 t.Fatal(err)
@@ -64,8 +67,9 @@ func TestComponentLevelsAndRotation(t *testing.T) {
6467 }
6568 }
6669 }
67- if _, err := os.Stat(path + ".1"); err != nil {
68- t.Fatal(err)
70+ runs, _ := filepath.Glob(filepath.Join(dir, "2*.log"))
71+ if len(runs) != keepRuns {
72+ t.Fatalf("kept %d runs, want %d", len(runs), keepRuns)
6973 }
7074 }
7175
@@ -7,6 +7,7 @@ import (
7 "os"7 "os"
8 "path/filepath"8 "path/filepath"
9 "testing"9 "testing"
10+ "time"
10 )11 )
11 12
12 func TestParseDebug(t *testing.T) {13 func TestParseDebug(t *testing.T) {
@@ -32,8 +33,10 @@ func TestParseDebug(t *testing.T) {
32 33
33 func TestComponentLevelsAndRotation(t *testing.T) {34 func TestComponentLevelsAndRotation(t *testing.T) {
34 t.Setenv("XDG_STATE_HOME", t.TempDir())35 t.Setenv("XDG_STATE_HOME", t.TempDir())
35- path := filepath.Join(os.Getenv("XDG_STATE_HOME"), "nefertty", "nefertty.log")36+ dir := filepath.Join(os.Getenv("XDG_STATE_HOME"), "nefertty", "runs")
36- for run := 0; run < 2; run++ {37+ path := filepath.Join(dir, "latest.log")
38+ for run := 0; run < keepRuns+2; run++ {
39+ time.Sleep(2 * time.Millisecond) // distinct millisecond timestamps
37 ctx, closeLog, err := Open(context.Background(), "info", "core")40 ctx, closeLog, err := Open(context.Background(), "info", "core")
38 if err != nil {41 if err != nil {
39 t.Fatal(err)42 t.Fatal(err)
@@ -64,8 +67,9 @@ func TestComponentLevelsAndRotation(t *testing.T) {
64 }67 }
65 }68 }
66 }69 }
67- if _, err := os.Stat(path + ".1"); err != nil {70+ runs, _ := filepath.Glob(filepath.Join(dir, "2*.log"))
68- t.Fatal(err)71+ if len(runs) != keepRuns {
72+ t.Fatalf("kept %d runs, want %d", len(runs), keepRuns)
69 }73 }
70 }74 }
71 75

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 25

Tags 10

↑↓ moveenter openesc close