logs.go

  1package cmd
  2
  3import (
  4	"bufio"
  5	"encoding/json"
  6	"fmt"
  7	"os"
  8	"path/filepath"
  9	"slices"
 10	"time"
 11
 12	"github.com/charmbracelet/crush/internal/config"
 13	"github.com/charmbracelet/log/v2"
 14	"github.com/nxadm/tail"
 15	"github.com/spf13/cobra"
 16)
 17
 18var logsCmd = &cobra.Command{
 19	Use:   "logs",
 20	Short: "View crush logs",
 21	Long:  `View the logs generated by Crush. This command allows you to see the log output for debugging and monitoring.`,
 22	RunE: func(cmd *cobra.Command, args []string) error {
 23		cwd, err := cmd.Flags().GetString("cwd")
 24		if err != nil {
 25			return fmt.Errorf("failed to get current working directory: %v", err)
 26		}
 27
 28		follow, err := cmd.Flags().GetBool("follow")
 29		if err != nil {
 30			return fmt.Errorf("failed to get follow flag: %v", err)
 31		}
 32
 33		log.SetLevel(log.DebugLevel)
 34		// Configure log to output to stdout instead of stderr
 35		log.SetOutput(os.Stdout)
 36
 37		cfg, err := config.Load(cwd, false)
 38		if err != nil {
 39			return fmt.Errorf("failed to load configuration: %v", err)
 40		}
 41		logsFile := filepath.Join(cfg.WorkingDir(), cfg.Options.DataDirectory, "logs", "crush.log")
 42		_, err = os.Stat(logsFile)
 43		if os.IsNotExist(err) {
 44			log.Warn("Looks like you are not in a crush project. No logs found.")
 45			return nil
 46		}
 47
 48		if follow {
 49			// Follow mode - tail the file continuously
 50			t, err := tail.TailFile(logsFile, tail.Config{Follow: true, ReOpen: true, Logger: tail.DiscardingLogger})
 51			if err != nil {
 52				return fmt.Errorf("failed to tail log file: %v", err)
 53			}
 54
 55			// Print the text of each received line
 56			for line := range t.Lines {
 57				printLogLine(line.Text)
 58			}
 59		} else {
 60			// Oneshot mode - read the entire file once
 61			file, err := os.Open(logsFile)
 62			if err != nil {
 63				return fmt.Errorf("failed to open log file: %v", err)
 64			}
 65			defer file.Close()
 66
 67			scanner := bufio.NewScanner(file)
 68			for scanner.Scan() {
 69				printLogLine(scanner.Text())
 70			}
 71
 72			if err := scanner.Err(); err != nil {
 73				return fmt.Errorf("failed to read log file: %v", err)
 74			}
 75		}
 76
 77		return nil
 78	},
 79}
 80
 81func init() {
 82	logsCmd.Flags().BoolP("follow", "f", false, "Follow log output")
 83	rootCmd.AddCommand(logsCmd)
 84}
 85
 86func printLogLine(lineText string) {
 87	var data map[string]any
 88	if err := json.Unmarshal([]byte(lineText), &data); err != nil {
 89		return
 90	}
 91	msg := data["msg"]
 92	level := data["level"]
 93	otherData := []any{}
 94	keys := []string{}
 95	for k := range data {
 96		keys = append(keys, k)
 97	}
 98	slices.Sort(keys)
 99	for _, k := range keys {
100		switch k {
101		case "msg", "level", "time":
102			continue
103		case "source":
104			source, ok := data[k].(map[string]any)
105			if !ok {
106				continue
107			}
108			sourceFile := fmt.Sprintf("%s:%d", source["file"], int(source["line"].(float64)))
109			otherData = append(otherData, "source", sourceFile)
110
111		default:
112			otherData = append(otherData, k, data[k])
113		}
114	}
115	log.SetTimeFunction(func(_ time.Time) time.Time {
116		// parse the timestamp from the log line if available
117		t, err := time.Parse(time.RFC3339, data["time"].(string))
118		if err != nil {
119			return time.Now() // fallback to current time if parsing fails
120		}
121		return t
122	})
123	switch level {
124	case "INFO":
125		log.Info(msg, otherData...)
126	case "DEBUG":
127		log.Debug(msg, otherData...)
128	case "ERROR":
129		log.Error(msg, otherData...)
130	case "WARN":
131		log.Warn(msg, otherData...)
132	default:
133		log.Info(msg, otherData...)
134	}
135}