crush_logs.go

  1package tools
  2
  3import (
  4	"context"
  5	_ "embed"
  6	"encoding/json"
  7	"fmt"
  8	"html/template"
  9	"io"
 10	"os"
 11	"path/filepath"
 12	"sort"
 13	"strconv"
 14	"strings"
 15	"time"
 16
 17	"charm.land/fantasy"
 18)
 19
 20const CrushLogsToolName = "crush_logs"
 21
 22//go:embed crush_logs.md.tpl
 23var crushLogsDescriptionTmpl []byte
 24
 25var crushLogsDescriptionTpl = template.Must(
 26	template.New("crushLogsDescription").
 27		Parse(string(crushLogsDescriptionTmpl)),
 28)
 29
 30type crushLogsDescriptionData struct {
 31	DefaultLines int
 32	MaxLines     int
 33}
 34
 35func crushLogsDescription() string {
 36	return renderTemplate(crushLogsDescriptionTpl, crushLogsDescriptionData{
 37		DefaultLines: defaultLogLines,
 38		MaxLines:     maxLogLines,
 39	})
 40}
 41
 42// Max line size to prevent memory issues with very long log lines (1 MB).
 43const maxLogLineSize = 1024 * 1024
 44
 45// Default and max line limits.
 46const (
 47	defaultLogLines = 50
 48	maxLogLines     = 100
 49)
 50
 51// Reserved fields that should not appear as extra key=value pairs.
 52// Case-insensitive matching is used.
 53var reservedFields = map[string]bool{
 54	"time":   true,
 55	"level":  true,
 56	"source": true,
 57	"msg":    true,
 58}
 59
 60// Sensitive field keys that should be redacted (matched case-insensitively).
 61var sensitiveKeys = []string{
 62	"authorization",
 63	"api-key",
 64	"api_key",
 65	"apikey",
 66	"token",
 67	"secret",
 68	"password",
 69	"credential",
 70}
 71
 72type CrushLogsParams struct {
 73	Lines int `json:"lines,omitempty" description:"Number of recent log entries to return (default 50, max 100)"`
 74}
 75
 76func NewCrushLogsTool(logFile string) fantasy.AgentTool {
 77	return fantasy.NewAgentTool(
 78		CrushLogsToolName,
 79		crushLogsDescription(),
 80		func(ctx context.Context, params CrushLogsParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
 81			result := runCrushLogs(logFile, params)
 82			return fantasy.NewTextResponse(result), nil
 83		},
 84	)
 85}
 86
 87// runCrushLogs reads and formats the last N log entries from the given file.
 88func runCrushLogs(logFile string, params CrushLogsParams) string {
 89	// Validate and clamp the lines parameter.
 90	lines := params.Lines
 91	if lines <= 0 {
 92		lines = defaultLogLines
 93	}
 94	if lines > maxLogLines {
 95		lines = maxLogLines
 96	}
 97
 98	// Check if file exists.
 99	info, err := os.Stat(logFile)
100	if err != nil {
101		if os.IsNotExist(err) {
102			return "No log file found"
103		}
104		return fmt.Sprintf("Error accessing log file: %v", err)
105	}
106
107	if info.Size() == 0 {
108		return "Log file is empty"
109	}
110
111	// Read the last N lines from the log file.
112	logEntries, err := readLastLines(logFile, lines)
113	if err != nil {
114		return fmt.Sprintf("Error reading log file: %v", err)
115	}
116
117	if len(logEntries) == 0 {
118		return "Log file is empty"
119	}
120
121	// Format and return the entries.
122	formatted := formatLogEntries(logEntries)
123	return strings.Join(formatted, "\n")
124}
125
126// readLastLines reads the last n lines from a file by seeking to the end and
127// scanning backwards. Lines exceeding maxLogLineSize are skipped.
128func readLastLines(filePath string, n int) ([]map[string]any, error) {
129	file, err := os.Open(filePath)
130	if err != nil {
131		return nil, err
132	}
133	defer file.Close()
134
135	stat, err := file.Stat()
136	if err != nil {
137		return nil, err
138	}
139
140	if stat.Size() == 0 {
141		return nil, nil
142	}
143
144	// Seek to end and read chunks backwards.
145	var entries []map[string]any
146	const chunkSize = 8192 // 8KB chunks
147
148	pos := stat.Size()
149	var remainder []byte
150
151	for pos > 0 && len(entries) < n {
152		chunkStart := max(pos-chunkSize, 0)
153
154		chunkLen := int(pos - chunkStart)
155		if chunkLen == 0 {
156			break
157		}
158
159		_, err := file.Seek(chunkStart, 0)
160		if err != nil {
161			return nil, err
162		}
163
164		chunk := make([]byte, chunkLen)
165		_, err = io.ReadFull(file, chunk)
166		if err != nil {
167			return nil, err
168		}
169
170		// Combine with remainder from previous (earlier) chunk.
171		data := append(chunk, remainder...)
172
173		// Split into lines (without the final incomplete line if any).
174		lines := splitLines(data)
175
176		// Keep the incomplete line for next iteration.
177		if len(data) > 0 && data[len(data)-1] != '\n' {
178			remainder = lines[len(lines)-1]
179			lines = lines[:len(lines)-1]
180		} else {
181			remainder = nil
182		}
183
184		// Parse lines from end to start to get most recent first.
185		for i := len(lines) - 1; i >= 0; i-- {
186			if len(lines[i]) > maxLogLineSize {
187				// Skip oversized lines silently.
188				continue
189			}
190
191			// Try to parse as JSON.
192			var entry map[string]any
193			if err := json.Unmarshal(lines[i], &entry); err != nil {
194				// Skip malformed lines silently.
195				continue
196			}
197
198			entries = append(entries, entry)
199			if len(entries) >= n {
200				break
201			}
202		}
203
204		pos = chunkStart
205	}
206
207	// Handle final remainder.
208	if len(remainder) > 0 && len(remainder) <= maxLogLineSize {
209		var entry map[string]any
210		if err := json.Unmarshal(remainder, &entry); err == nil {
211			if len(entries) < n {
212				entries = append(entries, entry)
213			}
214		}
215	}
216
217	// Reverse to get chronological order (oldest first).
218	for i, j := 0, len(entries)-1; i < j; i, j = i+1, j-1 {
219		entries[i], entries[j] = entries[j], entries[i]
220	}
221
222	return entries, nil
223}
224
225// splitLines splits data into lines without allocating strings.
226func splitLines(data []byte) [][]byte {
227	var lines [][]byte
228	start := 0
229	for i := range len(data) {
230		if data[i] == '\n' {
231			lines = append(lines, data[start:i])
232			start = i + 1
233		}
234	}
235	if start < len(data) {
236		lines = append(lines, data[start:])
237	}
238	return lines
239}
240
241// formatLogEntries formats log entries into compact text format.
242func formatLogEntries(entries []map[string]any) []string {
243	var result []string
244	for _, entry := range entries {
245		result = append(result, formatLogEntry(entry))
246	}
247	return result
248}
249
250// formatLogEntry formats a single log entry into compact text format:
251// TIMESTAMP LEVEL SOURCE:LINE MESSAGE key=value...
252func formatLogEntry(entry map[string]any) string {
253	var parts []string
254
255	// Extract and format timestamp (time-only, no date).
256	timeStr := extractTime(entry)
257	parts = append(parts, timeStr)
258
259	// Extract level.
260	level := extractLevel(entry)
261	parts = append(parts, level)
262
263	// Extract source.
264	source := extractSource(entry)
265	parts = append(parts, source)
266
267	// Extract message.
268	msg := extractMessage(entry)
269
270	// Collect extra fields (excluding reserved fields).
271	extraFields := extractExtraFields(entry)
272
273	// Build the output.
274	var b strings.Builder
275	for i, part := range parts {
276		if i > 0 {
277			b.WriteByte(' ')
278		}
279		b.WriteString(part)
280	}
281	b.WriteByte(' ')
282	b.WriteString(msg)
283
284	// Append sorted key=value pairs.
285	if len(extraFields) > 0 {
286		keys := make([]string, 0, len(extraFields))
287		for k := range extraFields {
288			keys = append(keys, k)
289		}
290		sort.Strings(keys)
291
292		for _, k := range keys {
293			b.WriteByte(' ')
294			b.WriteString(k)
295			b.WriteByte('=')
296			b.WriteString(formatValue(extraFields[k], k))
297		}
298	}
299
300	return b.String()
301}
302
303// extractTime extracts and formats the timestamp from a log entry.
304// Returns time-only format (15:04:05).
305func extractTime(entry map[string]any) string {
306	timeVal, ok := entry["time"]
307	if !ok {
308		return "--:--:--"
309	}
310
311	timeStr, ok := timeVal.(string)
312	if !ok {
313		return "--:--:--"
314	}
315
316	// Parse RFC3339 format.
317	t, err := time.Parse(time.RFC3339, timeStr)
318	if err != nil {
319		// Try other common formats.
320		t, err = time.Parse("2006-01-02T15:04:05", timeStr)
321		if err != nil {
322			return "--:--:--"
323		}
324	}
325
326	return t.Format("15:04:05")
327}
328
329// extractLevel extracts and normalizes the log level.
330func extractLevel(entry map[string]any) string {
331	levelVal, ok := entry["level"]
332	if !ok {
333		return "INFO"
334	}
335
336	levelStr, ok := levelVal.(string)
337	if !ok {
338		return "INFO"
339	}
340
341	switch strings.ToUpper(levelStr) {
342	case "DEBUG":
343		return "DEBUG"
344	case "INFO":
345		return "INFO"
346	case "WARN", "WARNING":
347		return "WARN"
348	case "ERROR":
349		return "ERROR"
350	default:
351		return "INFO"
352	}
353}
354
355// extractSource extracts the source file and line from a log entry.
356func extractSource(entry map[string]any) string {
357	sourceVal, ok := entry["source"]
358	if !ok {
359		return "unknown:0"
360	}
361
362	// Source can be a string or an object with "file" and "line".
363	switch s := sourceVal.(type) {
364	case string:
365		return filepath.Base(s)
366	case map[string]any:
367		fileVal, ok := s["file"].(string)
368		if !ok {
369			return "unknown:0"
370		}
371		fileVal = filepath.Base(fileVal)
372
373		lineNum := 0
374		if lineVal, ok := s["line"]; ok {
375			switch l := lineVal.(type) {
376			case float64:
377				lineNum = int(l)
378			case int:
379				lineNum = l
380			case json.Number:
381				if n, err := l.Int64(); err == nil {
382					lineNum = int(n)
383				}
384			}
385		}
386		return fmt.Sprintf("%s:%d", fileVal, lineNum)
387	default:
388		return "unknown:0"
389	}
390}
391
392// extractMessage extracts the log message.
393func extractMessage(entry map[string]any) string {
394	msgVal, ok := entry["msg"]
395	if !ok {
396		return ""
397	}
398
399	if msgStr, ok := msgVal.(string); ok {
400		return msgStr
401	}
402
403	return fmt.Sprintf("%v", msgVal)
404}
405
406// extractExtraFields extracts all non-reserved fields from a log entry.
407func extractExtraFields(entry map[string]any) map[string]any {
408	result := make(map[string]any)
409	for k, v := range entry {
410		// Skip reserved fields (case-insensitive).
411		if isReservedField(k) {
412			continue
413		}
414		// Redact sensitive values.
415		if isSensitiveKey(k) {
416			result[k] = "[REDACTED]"
417		} else {
418			result[k] = v
419		}
420	}
421	return result
422}
423
424// isReservedField checks if a field name is reserved (case-insensitive).
425func isReservedField(name string) bool {
426	lowerName := strings.ToLower(name)
427	return reservedFields[lowerName]
428}
429
430// isSensitiveKey checks if a key contains sensitive information (case-insensitive).
431func isSensitiveKey(name string) bool {
432	lowerName := strings.ToLower(name)
433	for _, sensitive := range sensitiveKeys {
434		if strings.Contains(lowerName, sensitive) {
435			return true
436		}
437	}
438	return false
439}
440
441// formatValue formats a value according to the quoting rules.
442func formatValue(value any, key string) string {
443	// Redact sensitive values (second check for safety).
444	if isSensitiveKey(key) {
445		return "[REDACTED]"
446	}
447
448	switch v := value.(type) {
449	case string:
450		return formatStringValue(v)
451	case float64:
452		// Check if it's actually an integer.
453		if v == float64(int64(v)) {
454			return strconv.FormatInt(int64(v), 10)
455		}
456		return strconv.FormatFloat(v, 'f', -1, 64)
457	case int:
458		return strconv.Itoa(v)
459	case int64:
460		return strconv.FormatInt(v, 10)
461	case bool:
462		return strconv.FormatBool(v)
463	case nil:
464		return "null"
465	case map[string]any, []any:
466		// Objects and arrays are JSON-encoded and quoted.
467		jsonBytes, err := json.Marshal(v)
468		if err != nil {
469			return quoteString(fmt.Sprintf("%v", v))
470		}
471		return quoteString(string(jsonBytes))
472	default:
473		return quoteString(fmt.Sprintf("%v", v))
474	}
475}
476
477// formatStringValue formats a string value with quoting if needed.
478func formatStringValue(s string) string {
479	// Quote if empty, contains spaces, =, newlines, or special chars.
480	needsQuote := len(s) == 0 ||
481		strings.ContainsAny(s, " =\n\r\t\"") ||
482		strings.Contains(s, "\\")
483
484	if !needsQuote {
485		return s
486	}
487
488	return quoteString(s)
489}
490
491// quoteString quotes a string with double quotes and escapes special characters.
492func quoteString(s string) string {
493	var b strings.Builder
494	b.WriteByte('"')
495	for i := 0; i < len(s); i++ {
496		c := s[i]
497		switch c {
498		case '"':
499			b.WriteString("\\\"")
500		case '\\':
501			b.WriteString("\\\\")
502		case '\n':
503			b.WriteString("\\n")
504		case '\r':
505			b.WriteString("\\r")
506		case '\t':
507			b.WriteString("\\t")
508		default:
509			b.WriteByte(c)
510		}
511	}
512	b.WriteByte('"')
513	return b.String()
514}