Skip to content

Commit 90ca8b5

Browse files
authored
File-based logging with intent-based applog facade (#192)
* feat: file-based logging with intent-based applog facade Closes #176. Adds a slog-backed file logger at ~/.config/cliamp/cliamp.log with a configurable level (log_level config key, --log-level CLI flag) and refactors applog into an intent-based facade with three tiers: - Debug/Info/Warn/Error: file only - Status: footer only (transient UI feedback) - UserWarn/UserError: both file and footer The footer ring buffer is preserved unchanged; the file sink is layered behind an atomic.Pointer[*slog.Logger] so log calls stay lock-free. Migrated all 11 spotify call sites: failures saving credentials and the auth-callback server error to UserError, reconnect/rate-limit warnings to UserWarn, 're-authenticated successfully' to Info+Status. Plugin-side logging and log rotation deferred to follow-ups. * docs(site): add diagnostic logging feature card Keeps site/index.html in sync with docs/configuration.md after the log_level config key was added in abfdc37. * Address CodeRabbit review - config: silently fall back to default for invalid log_level in TOML, matching the loader's behavior for other keys (volume, repeat, etc.) - spotify: extract duplicate re-auth message literal into a const - main: return applied level from initLogging so the startup log records the level that's actually in effect, not the raw config string * Drop Enabled gate from UserWarn/UserError The footer needs the formatted string regardless of file-log level, so the gate was paying for two fmt.Sprintf sites and a branch in exchange for skipping a sub-nanosecond slog dispatch. Diagnostic-only methods (Debug/Info/Warn/Error/logf) keep the gate where it actually avoids the Sprintf cost. * applog: use t.Cleanup for test logger close Resolves three errcheck violations from `defer closeFn()` by switching to `t.Cleanup(func() { _ = closeFn() })`. The explicit underscore documents the discard intent and t.Cleanup is the idiomatic place to register test resource teardown.
1 parent 63b1e69 commit 90ca8b5

12 files changed

Lines changed: 447 additions & 40 deletions

File tree

applog/applog.go

Lines changed: 148 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,46 +1,179 @@
1-
// Package applog provides a thread-safe in-app log buffer for messages that
2-
// would otherwise be written to stderr (which corrupts the TUI).
1+
// Package applog provides logging for cliamp.
2+
//
3+
// Two sinks are layered behind one API:
4+
//
5+
// - A file sink, written through log/slog, for diagnostic logs the user
6+
// reads after the fact (~/.config/cliamp/cliamp.log).
7+
// - An in-memory ring buffer drained by the TUI footer for short-lived,
8+
// user-facing messages. The buffer exists because writing to stderr
9+
// would corrupt the TUI.
10+
//
11+
// Callers pick by intent, not sink:
12+
//
13+
// - Debug/Info/Warn/Error write only to the file.
14+
// - Status writes only to the footer (transient UI
15+
// feedback that wouldn't help post-mortem debugging).
16+
// - UserWarn/UserError write to both.
17+
//
18+
// Init must be called once at startup. Calls before Init are silently
19+
// dropped on the file side; the footer buffer always works.
320
package applog
421

522
import (
23+
"context"
624
"fmt"
25+
"io"
26+
"log/slog"
27+
"os"
28+
"path/filepath"
29+
"strings"
730
"sync"
31+
"sync/atomic"
832
"time"
933
)
1034

11-
// Entry is a single log message with a timestamp.
35+
// Entry is a single footer message with a timestamp.
1236
type Entry struct {
1337
Text string
1438
At time.Time
1539
}
1640

41+
// Level is an alias for slog.Level so callers don't need a second import.
42+
type Level = slog.Level
43+
44+
const (
45+
LevelDebug = slog.LevelDebug
46+
LevelInfo = slog.LevelInfo
47+
LevelWarn = slog.LevelWarn
48+
LevelError = slog.LevelError
49+
)
50+
1751
const maxEntries = 4
1852

1953
var (
20-
mu sync.Mutex
21-
entries []Entry
54+
logger atomic.Pointer[slog.Logger]
55+
56+
mu sync.Mutex
57+
entries []Entry
58+
currentFile io.Closer
2259
)
2360

24-
// Printf writes a formatted log message to the buffer.
25-
func Printf(format string, args ...any) {
26-
msg := fmt.Sprintf(format, args...)
27-
mu.Lock()
28-
entries = append(entries, Entry{Text: msg, At: time.Now()})
29-
if len(entries) > maxEntries {
30-
entries = entries[len(entries)-maxEntries:]
61+
func init() {
62+
logger.Store(slog.New(slog.NewTextHandler(io.Discard, nil)))
63+
}
64+
65+
// Init opens path for append, installs a slog text handler at the given
66+
// level, and returns a close func. Calling Init twice closes the previous
67+
// file before swapping handlers.
68+
func Init(path string, level Level) (func() error, error) {
69+
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
70+
return nil, fmt.Errorf("create log dir: %w", err)
3171
}
72+
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
73+
if err != nil {
74+
return nil, fmt.Errorf("open log file: %w", err)
75+
}
76+
77+
h := slog.NewTextHandler(f, &slog.HandlerOptions{Level: level})
78+
logger.Store(slog.New(h))
79+
80+
mu.Lock()
81+
prev := currentFile
82+
currentFile = f
3283
mu.Unlock()
84+
if prev != nil {
85+
_ = prev.Close()
86+
}
87+
88+
return func() error {
89+
mu.Lock()
90+
defer mu.Unlock()
91+
if currentFile == nil {
92+
return nil
93+
}
94+
err := currentFile.Close()
95+
currentFile = nil
96+
logger.Store(slog.New(slog.NewTextHandler(io.Discard, nil)))
97+
return err
98+
}, nil
99+
}
100+
101+
// ParseLevel maps a string to a Level. Empty input maps to LevelInfo.
102+
// "warning" is accepted as an alias for "warn".
103+
func ParseLevel(s string) (Level, error) {
104+
s = strings.TrimSpace(s)
105+
if s == "" {
106+
return LevelInfo, nil
107+
}
108+
if strings.EqualFold(s, "warning") {
109+
return LevelWarn, nil
110+
}
111+
var lvl Level
112+
if err := lvl.UnmarshalText([]byte(strings.ToUpper(s))); err != nil {
113+
return LevelInfo, fmt.Errorf("invalid log level %q (want debug|info|warn|error)", s)
114+
}
115+
return lvl, nil
33116
}
34117

35-
// Drain returns all buffered entries and clears the buffer.
118+
// Debug, Info, Warn, Error log only to the file. Format follows fmt.Sprintf.
119+
// The level guard avoids paying the Sprintf cost when the level is filtered out.
120+
func Debug(format string, args ...any) { logf(slog.LevelDebug, format, args...) }
121+
func Info(format string, args ...any) { logf(slog.LevelInfo, format, args...) }
122+
func Warn(format string, args ...any) { logf(slog.LevelWarn, format, args...) }
123+
func Error(format string, args ...any) { logf(slog.LevelError, format, args...) }
124+
125+
// Status pushes a message into the footer buffer without writing to the
126+
// log file. Use for ephemeral, user-facing notifications that wouldn't help
127+
// post-mortem debugging.
128+
func Status(format string, args ...any) {
129+
pushFooter(fmt.Sprintf(format, args...))
130+
}
131+
132+
// UserWarn logs at warn level and pushes the same message into the footer.
133+
// The Sprintf cost is paid unconditionally because the footer needs the
134+
// formatted string, so no Enabled gate here.
135+
func UserWarn(format string, args ...any) {
136+
msg := fmt.Sprintf(format, args...)
137+
logger.Load().Warn(msg)
138+
pushFooter(msg)
139+
}
140+
141+
// UserError logs at error level and pushes the same message into the footer.
142+
func UserError(format string, args ...any) {
143+
msg := fmt.Sprintf(format, args...)
144+
logger.Load().Error(msg)
145+
pushFooter(msg)
146+
}
147+
148+
// Drain returns and clears all buffered footer entries.
36149
func Drain() []Entry {
37150
mu.Lock()
151+
defer mu.Unlock()
38152
if len(entries) == 0 {
39-
mu.Unlock()
40153
return nil
41154
}
42155
out := entries
43156
entries = nil
44-
mu.Unlock()
45157
return out
46158
}
159+
160+
func logf(level slog.Level, format string, args ...any) {
161+
lg := logger.Load()
162+
if !lg.Enabled(context.Background(), level) {
163+
return
164+
}
165+
lg.Log(context.Background(), level, fmt.Sprintf(format, args...))
166+
}
167+
168+
func pushFooter(msg string) {
169+
msg = strings.TrimRight(msg, "\n")
170+
if msg == "" {
171+
return
172+
}
173+
mu.Lock()
174+
entries = append(entries, Entry{Text: msg, At: time.Now()})
175+
if len(entries) > maxEntries {
176+
entries = entries[len(entries)-maxEntries:]
177+
}
178+
mu.Unlock()
179+
}

0 commit comments

Comments
 (0)