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		log.SetLevel(log.DebugLevel)
 36		// Configure log to output to stdout instead of stderr
 37		log.SetOutput(os.Stdout)
 38
 39		cfg, err := config.Load(cwd, false)
 40		if err != nil {
 41			return fmt.Errorf("failed to load configuration: %v", err)
 42		}
 43		logsFile := filepath.Join(cfg.WorkingDir(), cfg.Options.DataDirectory, "logs", "crush.log")
 44		_, err = os.Stat(logsFile)
 45		if os.IsNotExist(err) {
 46			log.Warn("Looks like you are not in a crush project. No logs found.")
 47			return nil
 48		}
 49
 50		if follow {
 51			// Follow mode - tail the file continuously
 52			t, err := tail.TailFile(logsFile, tail.Config{Follow: true, ReOpen: true, Logger: tail.DiscardingLogger})
 53			if err != nil {
 54				return fmt.Errorf("failed to tail log file: %v", err)
 55			}
 56
 57			// Print the text of each received line
 58			for line := range t.Lines {
 59				printLogLine(line.Text)
 60			}
 61		} else {
 62			// Oneshot mode - read the entire file once
 63			file, err := os.Open(logsFile)
 64			if err != nil {
 65				return fmt.Errorf("failed to open log file: %v", err)
 66			}
 67			defer file.Close()
 68
 69			reader := bufio.NewReader(file)
 70			for {
 71				line, err := reader.ReadString('\n')
 72				if err != nil {
 73					if err == io.EOF && line != "" {
 74						// Handle last line without newline
 75						printLogLine(line)
 76					}
 77					break
 78				}
 79				// Remove trailing newline
 80				line = strings.TrimSuffix(line, "\n")
 81				printLogLine(line)
 82			}
 83		}
 84
 85		return nil
 86	},
 87}
 88
 89func init() {
 90	logsCmd.Flags().BoolP("follow", "f", false, "Follow log output")
 91	rootCmd.AddCommand(logsCmd)
 92}
 93
 94func printLogLine(lineText string) {
 95	var data map[string]any
 96	if err := json.Unmarshal([]byte(lineText), &data); err != nil {
 97		return
 98	}
 99	msg := data["msg"]
100	level := data["level"]
101	otherData := []any{}
102	keys := []string{}
103	for k := range data {
104		keys = append(keys, k)
105	}
106	slices.Sort(keys)
107	for _, k := range keys {
108		switch k {
109		case "msg", "level", "time":
110			continue
111		case "source":
112			source, ok := data[k].(map[string]any)
113			if !ok {
114				continue
115			}
116			sourceFile := fmt.Sprintf("%s:%d", source["file"], int(source["line"].(float64)))
117			otherData = append(otherData, "source", sourceFile)
118
119		default:
120			otherData = append(otherData, k, data[k])
121		}
122	}
123	log.SetTimeFunction(func(_ time.Time) time.Time {
124		// parse the timestamp from the log line if available
125		t, err := time.Parse(time.RFC3339, data["time"].(string))
126		if err != nil {
127			return time.Now() // fallback to current time if parsing fails
128		}
129		return t
130	})
131	switch level {
132	case "INFO":
133		log.Info(msg, otherData...)
134	case "DEBUG":
135		log.Debug(msg, otherData...)
136	case "ERROR":
137		log.Error(msg, otherData...)
138	case "WARN":
139		log.Warn(msg, otherData...)
140	default:
141		log.Info(msg, otherData...)
142	}
143}