Skip to content

Commit 82b1498

Browse files
jesserobbinsclaude
andauthored
Add structured file logging with per-run correlation (#263)
I had already added logging for my own work on the deduplicate and fixup features I've been working on, and so am proposing durable structured logging to address #129 and likely other development work. @wesm does this do what you want? ## What changed - **`internal/logging`**: new package — fans log records to two sinks: human-readable text on stderr and a rotating JSON file under `<data dir>/logs/`. Every process gets a short `run_id` hex tag on every record for cross-invocation grep. - **`msgvault logs`**: new command to tail, filter, and follow the on-disk log files (`-f`, `--run-id`, `--level`, `--grep`, `--all`, `--path`). - **`root.go`**: installs the handler at startup; writes structured `msgvault startup` / `msgvault exit` lines; recovers panics to disk; new flags `--log-file`, `--log-level`, `--no-log-file`; new `[log]` config section. - **Operations**: search, stats, and list-accounts now emit structured start/done/fail lines with `duration_ms`. - **TUI**: `loadData`, `loadMessages`, and search operations emit structured lines; stderr logging is suppressed during alt-screen to prevent render corruption. - **Store**: new `--log-sql` and `--log-sql-slow-ms` flags; slow queries (>100 ms by default) emit WARN; every query optionally traced at INFO. ## Why Warnings and errors during sync workloads were silently dropped. The log file gives operators a reliable post-hoc record of what msgvault did and why it failed, without requiring stderr redirection. --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 392b05a commit 82b1498

File tree

13 files changed

+1909
-37
lines changed

13 files changed

+1909
-37
lines changed

cmd/msgvault/cmd/list_accounts.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,10 @@ func listLocalAccounts() error {
113113
}
114114
}
115115

116+
logger.Info("list-accounts",
117+
"sources", len(stats),
118+
)
119+
116120
if listAccountsJSON {
117121
return outputAccountsJSON(stats)
118122
}

cmd/msgvault/cmd/logs.go

Lines changed: 367 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,367 @@
1+
package cmd
2+
3+
import (
4+
"bufio"
5+
"context"
6+
"encoding/json"
7+
"fmt"
8+
"io"
9+
"os"
10+
"path/filepath"
11+
"sort"
12+
"strings"
13+
"time"
14+
15+
"github.com/spf13/cobra"
16+
)
17+
18+
var (
19+
logsFollow bool
20+
logsLines int
21+
logsRunID string
22+
logsLevel string
23+
logsAll bool
24+
logsGrep string
25+
logsPath bool
26+
)
27+
28+
var logsCmd = &cobra.Command{
29+
Use: "logs",
30+
Short: "View and tail msgvault's structured log files",
31+
Long: `Show msgvault's structured log output from the on-disk JSON logs
32+
written to <data dir>/logs.
33+
34+
By default this prints the last 50 lines of today's log file in a
35+
compact, human-friendly format (level + run_id + message + the
36+
interesting attrs). Use --follow to tail the file live, --run-id
37+
to filter to a single invocation, --level to filter by severity,
38+
or --grep to filter on a substring match across the whole record.
39+
40+
Examples:
41+
42+
msgvault logs # last 50 lines of today's log
43+
msgvault logs -n 200 --follow # tail with --follow
44+
msgvault logs --run-id a1b2c3d4 # just one run
45+
msgvault logs --level error # only errors
46+
msgvault logs --grep deduplicate # substring over the JSON
47+
msgvault logs --all # every log file we still have
48+
msgvault logs --path # print the log path and exit`,
49+
RunE: runLogsCmd,
50+
}
51+
52+
func runLogsCmd(cmd *cobra.Command, args []string) error {
53+
dir := cfg.LogsDir()
54+
55+
if logsPath {
56+
fmt.Println(dir)
57+
return nil
58+
}
59+
60+
files, err := findLogFiles(dir, logsAll)
61+
if err != nil {
62+
return err
63+
}
64+
if len(files) == 0 {
65+
fmt.Printf("No log files in %s\n", dir)
66+
fmt.Println("(Nothing has been logged yet, or --no-log-file was used.)")
67+
return nil
68+
}
69+
70+
filter := logFilter{
71+
RunID: logsRunID,
72+
Level: strings.ToLower(logsLevel),
73+
Grep: logsGrep,
74+
}
75+
76+
// Non-follow mode: load the requested file(s) and print the
77+
// last N filtered lines. "Last N" is computed against the
78+
// filtered subset so --run-id and --level behave intuitively.
79+
if !logsFollow {
80+
return printLogFiles(files, logsLines, filter, cmd.OutOrStdout())
81+
}
82+
83+
// Follow mode: print the tail of the most recent file and
84+
// then stream new lines. --all is ignored because tailing
85+
// rotated files would be a trap.
86+
latest := files[len(files)-1]
87+
if err := printLogFiles(
88+
[]string{latest}, logsLines, filter, cmd.OutOrStdout(),
89+
); err != nil {
90+
return err
91+
}
92+
return followLogFile(cmd.Context(), latest, filter, cmd.OutOrStdout())
93+
}
94+
95+
// findLogFiles returns the sorted list of log files to read.
96+
// When all is false, it returns only today's file (if it exists).
97+
func findLogFiles(dir string, all bool) ([]string, error) {
98+
if _, err := os.Stat(dir); err != nil {
99+
if os.IsNotExist(err) {
100+
return nil, nil
101+
}
102+
return nil, fmt.Errorf("stat logs dir: %w", err)
103+
}
104+
105+
if !all {
106+
name := fmt.Sprintf(
107+
"msgvault-%s.log", time.Now().UTC().Format("2006-01-02"),
108+
)
109+
path := filepath.Join(dir, name)
110+
if _, err := os.Stat(path); err == nil {
111+
return []string{path}, nil
112+
}
113+
// Fall through to the --all scan; maybe we only have
114+
// yesterday's file.
115+
}
116+
117+
entries, err := os.ReadDir(dir)
118+
if err != nil {
119+
return nil, fmt.Errorf("read logs dir: %w", err)
120+
}
121+
var files []string
122+
for _, e := range entries {
123+
if e.IsDir() {
124+
continue
125+
}
126+
n := e.Name()
127+
if !strings.HasPrefix(n, "msgvault-") ||
128+
(!strings.HasSuffix(n, ".log") && !strings.Contains(n, ".log.")) {
129+
continue
130+
}
131+
files = append(files, filepath.Join(dir, n))
132+
}
133+
sort.Slice(files, func(i, j int) bool {
134+
return logFileSortKey(files[i]) < logFileSortKey(files[j])
135+
})
136+
return files, nil
137+
}
138+
139+
// logFileSortKey returns a string that sorts log files chronologically:
140+
// rotated files (.log.1, .log.2) come before the active .log for the
141+
// same date. Higher rotation indices are older (.log.5 predates .log.1),
142+
// so they sort first by inverting the suffix.
143+
func logFileSortKey(path string) string {
144+
name := filepath.Base(path)
145+
// msgvault-2026-04-11.log -> date=2026-04-11 suffix=999 (active, last)
146+
// msgvault-2026-04-11.log.5 -> date=2026-04-11 suffix=000 (oldest rotation)
147+
// msgvault-2026-04-11.log.1 -> date=2026-04-11 suffix=004 (newest rotation)
148+
if idx := strings.LastIndex(name, ".log."); idx >= 0 {
149+
date := name[:idx+4] // through ".log"
150+
num := 0
151+
_, _ = fmt.Sscanf(name[idx+5:], "%d", &num)
152+
// Invert: higher rotation number = older = should sort first.
153+
// 999 is reserved for the active file, so cap at 998.
154+
inverted := 998 - num
155+
if inverted < 0 {
156+
inverted = 0
157+
}
158+
return fmt.Sprintf("%s.%03d", date, inverted)
159+
}
160+
// Active file (no rotation suffix) sorts after all rotations
161+
// for the same date.
162+
return name + ".999"
163+
}
164+
165+
// logFilter represents the user's --run-id / --level / --grep
166+
// filters. An empty field means "no filter on that axis".
167+
type logFilter struct {
168+
RunID string
169+
Level string
170+
Grep string
171+
}
172+
173+
// matches reports whether a record matches every active filter.
174+
func (f logFilter) matches(raw []byte, rec map[string]any) bool {
175+
if f.RunID != "" {
176+
if got, _ := rec["run_id"].(string); !strings.HasPrefix(got, f.RunID) {
177+
return false
178+
}
179+
}
180+
if f.Level != "" {
181+
if got, _ := rec["level"].(string); !strings.EqualFold(got, f.Level) {
182+
return false
183+
}
184+
}
185+
if f.Grep != "" {
186+
if !strings.Contains(string(raw), f.Grep) {
187+
return false
188+
}
189+
}
190+
return true
191+
}
192+
193+
// printLogFiles prints the last tailN filtered lines across the
194+
// supplied files. Keeping a fixed-size ring buffer keeps memory
195+
// bounded even on very large log files.
196+
func printLogFiles(
197+
files []string, tailN int, filter logFilter, out io.Writer,
198+
) error {
199+
if tailN <= 0 {
200+
tailN = 50
201+
}
202+
ring := make([]string, 0, tailN)
203+
push := func(line string) {
204+
if len(ring) == tailN {
205+
ring = ring[1:]
206+
}
207+
ring = append(ring, line)
208+
}
209+
210+
for _, path := range files {
211+
f, err := os.Open(path)
212+
if err != nil {
213+
return fmt.Errorf("open %s: %w", path, err)
214+
}
215+
scanner := bufio.NewScanner(f)
216+
scanner.Buffer(make([]byte, 1024*1024), 4*1024*1024)
217+
for scanner.Scan() {
218+
raw := scanner.Bytes()
219+
var rec map[string]any
220+
if err := json.Unmarshal(raw, &rec); err != nil {
221+
// Non-JSON lines (shouldn't happen in file
222+
// output, but be safe).
223+
continue
224+
}
225+
if !filter.matches(raw, rec) {
226+
continue
227+
}
228+
push(formatLogRecord(rec))
229+
}
230+
_ = f.Close()
231+
if err := scanner.Err(); err != nil {
232+
return fmt.Errorf("scan %s: %w", path, err)
233+
}
234+
}
235+
for _, line := range ring {
236+
_, _ = fmt.Fprintln(out, line)
237+
}
238+
return nil
239+
}
240+
241+
// followLogFile tails path for new lines as they're written and
242+
// prints those that match filter. Exits when the command context
243+
// is cancelled (Ctrl-C).
244+
func followLogFile(
245+
ctx context.Context, path string, filter logFilter, out io.Writer,
246+
) error {
247+
f, err := os.Open(path)
248+
if err != nil {
249+
return fmt.Errorf("open %s: %w", path, err)
250+
}
251+
defer func() { _ = f.Close() }()
252+
253+
if _, err := f.Seek(0, io.SeekEnd); err != nil {
254+
return fmt.Errorf("seek end: %w", err)
255+
}
256+
257+
reader := bufio.NewReader(f)
258+
var partial []byte
259+
for {
260+
select {
261+
case <-ctx.Done():
262+
return nil
263+
default:
264+
}
265+
line, err := reader.ReadBytes('\n')
266+
if len(line) > 0 {
267+
if len(partial) > 0 {
268+
line = append(partial, line...)
269+
partial = nil
270+
}
271+
// If the line doesn't end with a newline, it's a
272+
// partial read — buffer it until more data arrives.
273+
if line[len(line)-1] != '\n' {
274+
partial = append(partial[:0], line...)
275+
// fall through to the sleep
276+
} else {
277+
var rec map[string]any
278+
if json.Unmarshal(line, &rec) == nil &&
279+
filter.matches(line, rec) {
280+
_, _ = fmt.Fprintln(out, formatLogRecord(rec))
281+
}
282+
continue
283+
}
284+
}
285+
if err != nil && err != io.EOF {
286+
return err
287+
}
288+
select {
289+
case <-ctx.Done():
290+
return nil
291+
case <-time.After(300 * time.Millisecond):
292+
}
293+
}
294+
}
295+
296+
// formatLogRecord renders a JSON log record as a compact, human
297+
// readable line. The interesting attributes come after the
298+
// message; we deliberately drop the source attribute for brevity.
299+
func formatLogRecord(rec map[string]any) string {
300+
level, _ := rec["level"].(string)
301+
msg, _ := rec["msg"].(string)
302+
runID, _ := rec["run_id"].(string)
303+
ts, _ := rec["time"].(string)
304+
305+
// Collect the remaining interesting attributes in a stable
306+
// order. Known low-signal keys are skipped.
307+
skip := map[string]bool{
308+
"level": true, "msg": true, "run_id": true,
309+
"time": true, "source": true,
310+
}
311+
keys := make([]string, 0, len(rec))
312+
for k := range rec {
313+
if !skip[k] {
314+
keys = append(keys, k)
315+
}
316+
}
317+
sort.Strings(keys)
318+
319+
var b strings.Builder
320+
if ts != "" {
321+
// Keep just HH:MM:SS for readability — the file name
322+
// already encodes the date.
323+
if t, err := time.Parse(time.RFC3339Nano, ts); err == nil {
324+
b.WriteString(t.Local().Format("15:04:05"))
325+
b.WriteByte(' ')
326+
}
327+
}
328+
if level != "" {
329+
fmt.Fprintf(&b, "%-5s", level)
330+
b.WriteByte(' ')
331+
}
332+
if runID != "" {
333+
// Show first 6 chars so the column stays aligned.
334+
short := runID
335+
if len(short) > 6 {
336+
short = short[:6]
337+
}
338+
b.WriteString(short)
339+
b.WriteByte(' ')
340+
}
341+
b.WriteString(msg)
342+
for _, k := range keys {
343+
b.WriteString(" ")
344+
b.WriteString(k)
345+
b.WriteString("=")
346+
fmt.Fprint(&b, rec[k])
347+
}
348+
return b.String()
349+
}
350+
351+
func init() {
352+
logsCmd.Flags().BoolVarP(&logsFollow, "follow", "f", false,
353+
"follow today's log file as new lines are written")
354+
logsCmd.Flags().IntVarP(&logsLines, "lines", "n", 50,
355+
"number of trailing lines to show before following")
356+
logsCmd.Flags().StringVar(&logsRunID, "run-id", "",
357+
"filter to a single run (matches on prefix)")
358+
logsCmd.Flags().StringVar(&logsLevel, "level", "",
359+
"filter by log level: debug, info, warn, error")
360+
logsCmd.Flags().StringVar(&logsGrep, "grep", "",
361+
"substring filter applied to the raw JSON record")
362+
logsCmd.Flags().BoolVar(&logsAll, "all", false,
363+
"read every log file in the logs directory, not just today's")
364+
logsCmd.Flags().BoolVar(&logsPath, "path", false,
365+
"print the log directory path and exit")
366+
rootCmd.AddCommand(logsCmd)
367+
}

0 commit comments

Comments
 (0)