logs.go

  1package cmd
  2
  3import (
  4	"bufio"
  5	"encoding/json"
  6	"fmt"
  7	"io"
  8	"os"
  9	"path/filepath"
 10	"slices"
 11	"strings"
 12	"time"
 13
 14	"github.com/charmbracelet/crush/internal/config"
 15	"github.com/charmbracelet/log/v2"
 16	"github.com/nxadm/tail"
 17	"github.com/spf13/cobra"
 18)
 19
 20var logsCmd = &cobra.Command{
 21	Use:   "logs",
 22	Short: "View crush logs",
 23	Long:  `View the logs generated by Crush. This command allows you to see the log output for debugging and monitoring.`,
 24	RunE: func(cmd *cobra.Command, args []string) error {
 25		cwd, err := cmd.Flags().GetString("cwd")
 26		if err != nil {
 27			return fmt.Errorf("failed to get current working directory: %v", err)
 28		}
 29
 30		follow, err := cmd.Flags().GetBool("follow")
 31		if err != nil {
 32			return fmt.Errorf("failed to get follow flag: %v", err)
 33		}
 34
 35		tailLines, err := cmd.Flags().GetInt("tail")
 36		if err != nil {
 37			return fmt.Errorf("failed to get tail flag: %v", err)
 38		}
 39
 40		log.SetLevel(log.DebugLevel)
 41		// Configure log to output to stdout instead of stderr
 42		log.SetOutput(os.Stdout)
 43
 44		cfg, err := config.Load(cwd, false)
 45		if err != nil {
 46			return fmt.Errorf("failed to load configuration: %v", err)
 47		}
 48		logsFile := filepath.Join(cfg.WorkingDir(), cfg.Options.DataDirectory, "logs", "crush.log")
 49		_, err = os.Stat(logsFile)
 50		if os.IsNotExist(err) {
 51			log.Warn("Looks like you are not in a crush project. No logs found.")
 52			return nil
 53		}
 54
 55		if follow {
 56			// Follow mode - tail the file continuously
 57			t, err := tail.TailFile(logsFile, tail.Config{Follow: true, ReOpen: true, Logger: tail.DiscardingLogger})
 58			if err != nil {
 59				return fmt.Errorf("failed to tail log file: %v", err)
 60			}
 61
 62			// Print the text of each received line
 63			for line := range t.Lines {
 64				printLogLine(line.Text)
 65			}
 66		} else if tailLines > 0 {
 67			// Tail mode - show last N lines
 68			lines, err := readLastNLines(logsFile, tailLines)
 69			if err != nil {
 70				return fmt.Errorf("failed to read last %d lines: %v", tailLines, err)
 71			}
 72			for _, line := range lines {
 73				printLogLine(line)
 74			}
 75		} else {
 76			// Oneshot mode - read the entire file once
 77			file, err := os.Open(logsFile)
 78			if err != nil {
 79				return fmt.Errorf("failed to open log file: %v", err)
 80			}
 81			defer file.Close()
 82
 83			reader := bufio.NewReader(file)
 84			for {
 85				line, err := reader.ReadString('\n')
 86				if err != nil {
 87					if err == io.EOF && line != "" {
 88						// Handle last line without newline
 89						printLogLine(line)
 90					}
 91					break
 92				}
 93				// Remove trailing newline
 94				line = strings.TrimSuffix(line, "\n")
 95				printLogLine(line)
 96			}
 97		}
 98
 99		return nil
100	},
101}
102
103func init() {
104	logsCmd.Flags().BoolP("follow", "f", false, "Follow log output")
105	logsCmd.Flags().IntP("tail", "t", 0, "Show only the last N lines")
106	rootCmd.AddCommand(logsCmd)
107}
108
109// readLastNLines reads the last N lines from a file using a simple circular buffer approach
110func readLastNLines(filename string, n int) ([]string, error) {
111	file, err := os.Open(filename)
112	if err != nil {
113		return nil, err
114	}
115	defer file.Close()
116
117	// Use a circular buffer to keep only the last N lines
118	lines := make([]string, n)
119	count := 0
120	index := 0
121
122	reader := bufio.NewReader(file)
123	for {
124		line, err := reader.ReadString('\n')
125		if err != nil {
126			if err == io.EOF && line != "" {
127				// Handle last line without newline
128				line = strings.TrimSuffix(line, "\n")
129				lines[index] = line
130				count++
131				index = (index + 1) % n
132			}
133			break
134		}
135		// Remove trailing newline
136		line = strings.TrimSuffix(line, "\n")
137		lines[index] = line
138		count++
139		index = (index + 1) % n
140	}
141
142	// Extract the last N lines in correct order
143	if count <= n {
144		// We have fewer lines than requested, return them all
145		return lines[:count], nil
146	}
147
148	// We have more lines than requested, extract from circular buffer
149	result := make([]string, n)
150	for i := range n {
151		result[i] = lines[(index+i)%n]
152	}
153	return result, nil
154}
155
156func printLogLine(lineText string) {
157	var data map[string]any
158	if err := json.Unmarshal([]byte(lineText), &data); err != nil {
159		return
160	}
161	msg := data["msg"]
162	level := data["level"]
163	otherData := []any{}
164	keys := []string{}
165	for k := range data {
166		keys = append(keys, k)
167	}
168	slices.Sort(keys)
169	for _, k := range keys {
170		switch k {
171		case "msg", "level", "time":
172			continue
173		case "source":
174			source, ok := data[k].(map[string]any)
175			if !ok {
176				continue
177			}
178			sourceFile := fmt.Sprintf("%s:%d", source["file"], int(source["line"].(float64)))
179			otherData = append(otherData, "source", sourceFile)
180
181		default:
182			otherData = append(otherData, k, data[k])
183		}
184	}
185	log.SetTimeFunction(func(_ time.Time) time.Time {
186		// parse the timestamp from the log line if available
187		t, err := time.Parse(time.RFC3339, data["time"].(string))
188		if err != nil {
189			return time.Now() // fallback to current time if parsing fails
190		}
191		return t
192	})
193	switch level {
194	case "INFO":
195		log.Info(msg, otherData...)
196	case "DEBUG":
197		log.Debug(msg, otherData...)
198	case "ERROR":
199		log.Error(msg, otherData...)
200	case "WARN":
201		log.Warn(msg, otherData...)
202	default:
203		log.Info(msg, otherData...)
204	}
205}