logs.go 4.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207
  1. package cmd
  2. import (
  3. "context"
  4. "encoding/json"
  5. "fmt"
  6. "io"
  7. "os"
  8. "path/filepath"
  9. "slices"
  10. "time"
  11. "github.com/charmbracelet/crush/internal/config"
  12. "github.com/charmbracelet/log/v2"
  13. "github.com/nxadm/tail"
  14. "github.com/spf13/cobra"
  15. )
  16. const defaultTailLines = 1000
  17. var logsCmd = &cobra.Command{
  18. Use: "logs",
  19. Short: "View crush logs",
  20. Long: `View the logs generated by Crush. This command allows you to see the log output for debugging and monitoring.`,
  21. RunE: func(cmd *cobra.Command, args []string) error {
  22. cwd, err := cmd.Flags().GetString("cwd")
  23. if err != nil {
  24. return fmt.Errorf("failed to get current working directory: %v", err)
  25. }
  26. follow, err := cmd.Flags().GetBool("follow")
  27. if err != nil {
  28. return fmt.Errorf("failed to get follow flag: %v", err)
  29. }
  30. tailLines, err := cmd.Flags().GetInt("tail")
  31. if err != nil {
  32. return fmt.Errorf("failed to get tail flag: %v", err)
  33. }
  34. log.SetLevel(log.DebugLevel)
  35. log.SetOutput(os.Stdout)
  36. cfg, err := config.Load(cwd, false)
  37. if err != nil {
  38. return fmt.Errorf("failed to load configuration: %v", err)
  39. }
  40. logsFile := filepath.Join(cfg.WorkingDir(), cfg.Options.DataDirectory, "logs", "crush.log")
  41. _, err = os.Stat(logsFile)
  42. if os.IsNotExist(err) {
  43. log.Warn("Looks like you are not in a crush project. No logs found.")
  44. return nil
  45. }
  46. if follow {
  47. return followLogs(cmd.Context(), logsFile, tailLines)
  48. }
  49. return showLogs(logsFile, tailLines)
  50. },
  51. }
  52. func init() {
  53. logsCmd.Flags().BoolP("follow", "f", false, "Follow log output")
  54. logsCmd.Flags().IntP("tail", "t", defaultTailLines, "Show only the last N lines default: 1000 for performance")
  55. rootCmd.AddCommand(logsCmd)
  56. }
  57. func followLogs(ctx context.Context, logsFile string, tailLines int) error {
  58. t, err := tail.TailFile(logsFile, tail.Config{
  59. Follow: false,
  60. ReOpen: false,
  61. Logger: tail.DiscardingLogger,
  62. })
  63. if err != nil {
  64. return fmt.Errorf("failed to tail log file: %v", err)
  65. }
  66. var lines []string
  67. for line := range t.Lines {
  68. if line.Err != nil {
  69. continue
  70. }
  71. lines = append(lines, line.Text)
  72. if len(lines) > tailLines {
  73. lines = lines[len(lines)-tailLines:]
  74. }
  75. }
  76. t.Stop()
  77. for _, line := range lines {
  78. printLogLine(line)
  79. }
  80. if len(lines) == tailLines {
  81. fmt.Fprintf(os.Stderr, "\nShowing last %d lines. Full logs available at: %s\n", tailLines, logsFile)
  82. fmt.Fprintf(os.Stderr, "Following new log entries...\n\n")
  83. }
  84. t, err = tail.TailFile(logsFile, tail.Config{
  85. Follow: true,
  86. ReOpen: true,
  87. Logger: tail.DiscardingLogger,
  88. Location: &tail.SeekInfo{Offset: 0, Whence: io.SeekEnd},
  89. })
  90. if err != nil {
  91. return fmt.Errorf("failed to tail log file: %v", err)
  92. }
  93. defer t.Stop()
  94. for {
  95. select {
  96. case line := <-t.Lines:
  97. if line.Err != nil {
  98. continue
  99. }
  100. printLogLine(line.Text)
  101. case <-ctx.Done():
  102. return nil
  103. }
  104. }
  105. }
  106. func showLogs(logsFile string, tailLines int) error {
  107. t, err := tail.TailFile(logsFile, tail.Config{
  108. Follow: false,
  109. ReOpen: false,
  110. Logger: tail.DiscardingLogger,
  111. MaxLineSize: 0,
  112. })
  113. if err != nil {
  114. return fmt.Errorf("failed to tail log file: %v", err)
  115. }
  116. defer t.Stop()
  117. var lines []string
  118. for line := range t.Lines {
  119. if line.Err != nil {
  120. continue
  121. }
  122. lines = append(lines, line.Text)
  123. if len(lines) > tailLines {
  124. lines = lines[len(lines)-tailLines:]
  125. }
  126. }
  127. for _, line := range lines {
  128. printLogLine(line)
  129. }
  130. if len(lines) == tailLines {
  131. fmt.Fprintf(os.Stderr, "\nShowing last %d lines. Full logs available at: %s\n", tailLines, logsFile)
  132. }
  133. return nil
  134. }
  135. func printLogLine(lineText string) {
  136. var data map[string]any
  137. if err := json.Unmarshal([]byte(lineText), &data); err != nil {
  138. return
  139. }
  140. msg := data["msg"]
  141. level := data["level"]
  142. otherData := []any{}
  143. keys := []string{}
  144. for k := range data {
  145. keys = append(keys, k)
  146. }
  147. slices.Sort(keys)
  148. for _, k := range keys {
  149. switch k {
  150. case "msg", "level", "time":
  151. continue
  152. case "source":
  153. source, ok := data[k].(map[string]any)
  154. if !ok {
  155. continue
  156. }
  157. sourceFile := fmt.Sprintf("%s:%d", source["file"], int(source["line"].(float64)))
  158. otherData = append(otherData, "source", sourceFile)
  159. default:
  160. otherData = append(otherData, k, data[k])
  161. }
  162. }
  163. log.SetTimeFunction(func(_ time.Time) time.Time {
  164. // parse the timestamp from the log line if available
  165. t, err := time.Parse(time.RFC3339, data["time"].(string))
  166. if err != nil {
  167. return time.Now() // fallback to current time if parsing fails
  168. }
  169. return t
  170. })
  171. switch level {
  172. case "INFO":
  173. log.Info(msg, otherData...)
  174. case "DEBUG":
  175. log.Debug(msg, otherData...)
  176. case "ERROR":
  177. log.Error(msg, otherData...)
  178. case "WARN":
  179. log.Warn(msg, otherData...)
  180. default:
  181. log.Info(msg, otherData...)
  182. }
  183. }