Add real-time log monitoring and system stats dashboard
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>
This commit is contained in:
95
internal/monitoring/writer.go
Normal file
95
internal/monitoring/writer.go
Normal file
@@ -0,0 +1,95 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user