Implements a comprehensive monitoring system for the admin interface: Backend: - New monitoring package with Redis ring buffer for log storage - Zerolog MultiWriter to capture logs to Redis - System stats collection (CPU, memory, disk, goroutines, GC) - HTTP metrics middleware (request counts, latency, error rates) - Asynq queue stats for worker process - WebSocket endpoint for real-time log streaming - Admin auth middleware now accepts token in query params (for WebSocket) Frontend: - New monitoring page with tabs (Overview, Logs, API Stats, Worker Stats) - Real-time log viewer with level filtering and search - System stats cards showing CPU, memory, goroutines, uptime - HTTP endpoint statistics table - Asynq queue depth visualization - Enable/disable monitoring toggle in settings Memory safeguards: - Max 200 unique endpoints tracked - Hourly stats reset to prevent unbounded growth - Max 1000 log entries in ring buffer - Max 1000 latency samples for P95 calculation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
96 lines
2.2 KiB
Go
96 lines
2.2 KiB
Go
package monitoring
|
|
|
|
import (
|
|
"encoding/json"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// RedisLogWriter implements io.Writer to capture zerolog output to Redis
|
|
type RedisLogWriter struct {
|
|
buffer *LogBuffer
|
|
process string
|
|
enabled atomic.Bool
|
|
}
|
|
|
|
// NewRedisLogWriter creates a new writer that captures logs to Redis
|
|
func NewRedisLogWriter(buffer *LogBuffer, process string) *RedisLogWriter {
|
|
w := &RedisLogWriter{
|
|
buffer: buffer,
|
|
process: process,
|
|
}
|
|
w.enabled.Store(true) // enabled by default
|
|
return w
|
|
}
|
|
|
|
// SetEnabled enables or disables log capture to Redis
|
|
func (w *RedisLogWriter) SetEnabled(enabled bool) {
|
|
w.enabled.Store(enabled)
|
|
}
|
|
|
|
// IsEnabled returns whether log capture is enabled
|
|
func (w *RedisLogWriter) IsEnabled() bool {
|
|
return w.enabled.Load()
|
|
}
|
|
|
|
// Write implements io.Writer interface
|
|
// It parses zerolog JSON output and writes to Redis asynchronously
|
|
func (w *RedisLogWriter) Write(p []byte) (n int, err error) {
|
|
// Skip if monitoring is disabled
|
|
if !w.enabled.Load() {
|
|
return len(p), nil
|
|
}
|
|
|
|
// Parse zerolog JSON output
|
|
var raw map[string]any
|
|
if err := json.Unmarshal(p, &raw); err != nil {
|
|
// Not valid JSON, skip (could be console writer output)
|
|
return len(p), nil
|
|
}
|
|
|
|
// Build log entry
|
|
entry := LogEntry{
|
|
ID: uuid.NewString(),
|
|
Timestamp: time.Now().UTC(),
|
|
Process: w.process,
|
|
Fields: make(map[string]any),
|
|
}
|
|
|
|
// Extract standard zerolog fields
|
|
if lvl, ok := raw["level"].(string); ok {
|
|
entry.Level = lvl
|
|
}
|
|
if msg, ok := raw["message"].(string); ok {
|
|
entry.Message = msg
|
|
}
|
|
if caller, ok := raw["caller"].(string); ok {
|
|
entry.Caller = caller
|
|
}
|
|
|
|
// Extract timestamp if present (zerolog may include it)
|
|
if ts, ok := raw["time"].(string); ok {
|
|
if parsed, err := time.Parse(time.RFC3339, ts); err == nil {
|
|
entry.Timestamp = parsed
|
|
}
|
|
}
|
|
|
|
// Copy additional fields (excluding standard ones)
|
|
for k, v := range raw {
|
|
switch k {
|
|
case "level", "message", "caller", "time":
|
|
// Skip standard fields
|
|
default:
|
|
entry.Fields[k] = v
|
|
}
|
|
}
|
|
|
|
// Write to Redis asynchronously to avoid blocking
|
|
go func() {
|
|
_ = w.buffer.Push(entry) // Ignore errors to avoid blocking log output
|
|
}()
|
|
|
|
return len(p), nil
|
|
}
|