|
| 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