on main help sign in
Merge pull request #139 from charmbracelet/feature/log-stdout
659f3ccKujtim Hoxha
cmd/logs.go+159−53
@@ -1,11 +1,14 @@
1package cmd
1package cmd
2
2
3import (
3import (
4+ "bufio"
4 "encoding/json"
5 "encoding/json"
5 "fmt"
6 "fmt"
7+ "io"
6 "os"
8 "os"
7 "path/filepath"
9 "path/filepath"
8 "slices"
10 "slices"
11+ "strings"
9 "time"
12 "time"
10
13
11 "github.com/charmbracelet/crush/internal/config"
14 "github.com/charmbracelet/crush/internal/config"
@@ -14,20 +17,30 @@ import (
14 "github.com/spf13/cobra"
17 "github.com/spf13/cobra"
15)
18)
16
19
17−func init() {
18− rootCmd.AddCommand(logsCmd)
19−}
20−
21var logsCmd = &cobra.Command{
20var logsCmd = &cobra.Command{
22 Use: "logs",
21 Use: "logs",
23 Short: "View crush logs",
22 Short: "View crush logs",
24− Long: `View the logs generated by Crush. This command allows you to see the log output for debugging and monitoring purposes.`,
23+ Long: `View the logs generated by Crush. This command allows you to see the log output for debugging and monitoring.`,
25 RunE: func(cmd *cobra.Command, args []string) error {
24 RunE: func(cmd *cobra.Command, args []string) error {
26 cwd, err := cmd.Flags().GetString("cwd")
25 cwd, err := cmd.Flags().GetString("cwd")
27 if err != nil {
26 if err != nil {
28 return fmt.Errorf("failed to get current working directory: %v", err)
27 return fmt.Errorf("failed to get current working directory: %v", err)
29 }
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+
30 log.SetLevel(log.DebugLevel)
40 log.SetLevel(log.DebugLevel)
41+ // Configure log to output to stdout instead of stderr
42+ log.SetOutput(os.Stdout)
43+
31 cfg, err := config.Load(cwd, false)
44 cfg, err := config.Load(cwd, false)
32 if err != nil {
45 if err != nil {
33 return fmt.Errorf("failed to load configuration: %v", err)
46 return fmt.Errorf("failed to load configuration: %v", err)
@@ -38,62 +51,155 @@ var logsCmd = &cobra.Command{
38 log.Warn("Looks like you are not in a crush project. No logs found.")
51 log.Warn("Looks like you are not in a crush project. No logs found.")
39 return nil
52 return nil
40 }
53 }
41− t, err := tail.TailFile(logsFile, tail.Config{Follow: true, ReOpen: true, Logger: tail.DiscardingLogger})
42− if err != nil {
43− return fmt.Errorf("failed to tail log file: %v", err)
44− }
45
54
46− // Print the text of each received line
55+ if follow {
47− for line := range t.Lines {
56+ // Follow mode - tail the file continuously
48− var data map[string]any
57+ t, err := tail.TailFile(logsFile, tail.Config{Follow: true, ReOpen: true, Logger: tail.DiscardingLogger})
49− if err := json.Unmarshal([]byte(line.Text), &data); err != nil {
58+ if err != nil {
50− continue
59+ return fmt.Errorf("failed to tail log file: %v", err)
51 }
60 }
52− msg := data["msg"]
53− level := data["level"]
54− otherData := []any{}
55− keys := []string{}
56− for k := range data {
57− keys = append(keys, k)
58− }
59− slices.Sort(keys)
60− for _, k := range keys {
61− switch k {
62− case "msg", "level", "time":
63− continue
64− case "source":
65− source, ok := data[k].(map[string]any)
66− if !ok {
67− continue
68− }
69− sourceFile := fmt.Sprintf("%s:%d", source["file"], int(source["line"].(float64)))
70− otherData = append(otherData, "source", sourceFile)
71
61
72− default:
62+ // Print the text of each received line
73− otherData = append(otherData, k, data[k])
63+ for line := range t.Lines {
74− }
64+ printLogLine(line.Text)
75 }
65 }
76− log.SetTimeFunction(func(_ time.Time) time.Time {
66+ } else if tailLines > 0 {
77− // parse the timestamp from the log line if available
67+ // Tail mode - show last N lines
78− t, err := time.Parse(time.RFC3339, data["time"].(string))
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')
79 if err != nil {
86 if err != nil {
80− return time.Now() // fallback to current time if parsing fails
87+ if err == io.EOF && line != "" {
88+ // Handle last line without newline
89+ printLogLine(line)
90+ }
91+ break
81 }
92 }
82− return t
93+ // Remove trailing newline
83− })
94+ line = strings.TrimSuffix(line, "\n")
84− switch level {
95+ printLogLine(line)
85− case "INFO":
86− log.Info(msg, otherData...)
87− case "DEBUG":
88− log.Debug(msg, otherData...)
89− case "ERROR":
90− log.Error(msg, otherData...)
91− case "WARN":
92− log.Warn(msg, otherData...)
93− default:
94− log.Info(msg, otherData...)
95 }
96 }
96 }
97 }
98+
97 return nil
99 return nil
98 },
100 },
99}
101}
102+
103+func 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
110+func 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+
156+func 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+}