Files
Redflag/aggregator-server/internal/api/handlers/client_errors.go
Fimeg 62697df112 v0.1.27 release: Complete implementation
Features:
- Error logging system with ETHOS #1 compliance
- Command factory pattern with UUID generation
- Hardware binding with machine fingerprint validation
- Ed25519 cryptographic signing for updates
- Deduplication and idempotency for commands
- Circuit breakers and retry logic
- Frontend error logging integration

Bug Fixes:
- Version display using compile-time injection
- Migration 017 CONCURRENTLY issue resolved
- Docker build context fixes
- Rate limiting implementation verified

Documentation:
- README updated to reflect actual implementation
- v0.1.27 inventory analysis added
2025-12-20 13:47:36 -05:00

224 lines
6.8 KiB
Go

package handlers
import (
"encoding/json"
"fmt"
"log"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
)
// ClientErrorHandler handles frontend error logging per ETHOS #1
type ClientErrorHandler struct {
db *sqlx.DB
}
// NewClientErrorHandler creates a new error handler
func NewClientErrorHandler(db *sqlx.DB) *ClientErrorHandler {
return &ClientErrorHandler{db: db}
}
// GetErrorsResponse represents paginated error list
type GetErrorsResponse struct {
Errors []ClientErrorResponse `json:"errors"`
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
TotalPages int `json:"total_pages"`
}
// ClientErrorResponse represents a single error in response
type ClientErrorResponse struct {
ID string `json:"id"`
AgentID string `json:"agent_id,omitempty"`
Subsystem string `json:"subsystem"`
ErrorType string `json:"error_type"`
Message string `json:"message"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
URL string `json:"url"`
CreatedAt time.Time `json:"created_at"`
}
// GetErrors returns paginated error logs (admin only)
func (h *ClientErrorHandler) GetErrors(c *gin.Context) {
// Parse pagination params
page := 1
pageSize := 50
if p, ok := c.GetQuery("page"); ok {
fmt.Sscanf(p, "%d", &page)
}
if ps, ok := c.GetQuery("page_size"); ok {
fmt.Sscanf(ps, "%d", &pageSize)
}
if pageSize > 100 {
pageSize = 100 // Max page size
}
// Parse filters
subsystem := c.Query("subsystem")
errorType := c.Query("error_type")
agentIDStr := c.Query("agent_id")
// Build query
query := `SELECT id, agent_id, subsystem, error_type, message, metadata, url, created_at
FROM client_errors
WHERE 1=1`
params := map[string]interface{}{}
if subsystem != "" {
query += " AND subsystem = :subsystem"
params["subsystem"] = subsystem
}
if errorType != "" {
query += " AND error_type = :error_type"
params["error_type"] = errorType
}
if agentIDStr != "" {
query += " AND agent_id = :agent_id"
params["agent_id"] = agentIDStr
}
query += " ORDER BY created_at DESC LIMIT :limit OFFSET :offset"
params["limit"] = pageSize
params["offset"] = (page - 1) * pageSize
// Execute query
var errors []ClientErrorResponse
if err := h.db.Select(&errors, query, params); err != nil {
log.Printf("[ERROR] [server] [client_error] query_failed error=\"%v\"", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
return
}
// Get total count
countQuery := `SELECT COUNT(*) FROM client_errors WHERE 1=1`
if subsystem != "" {
countQuery += " AND subsystem = :subsystem"
}
if errorType != "" {
countQuery += " AND error_type = :error_type"
}
if agentIDStr != "" {
countQuery += " AND agent_id = :agent_id"
}
var total int64
if err := h.db.Get(&total, countQuery, params); err != nil {
log.Printf("[ERROR] [server] [client_error] count_failed error=\"%v\"", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "count failed"})
return
}
totalPages := int((total + int64(pageSize) - 1) / int64(pageSize))
response := GetErrorsResponse{
Errors: errors,
Total: total,
Page: page,
PageSize: pageSize,
TotalPages: totalPages,
}
c.JSON(http.StatusOK, response)
}
// LogErrorRequest represents a client error log entry
type LogErrorRequest struct {
Subsystem string `json:"subsystem" binding:"required"`
ErrorType string `json:"error_type" binding:"required,oneof=javascript_error api_error ui_error validation_error"`
Message string `json:"message" binding:"required,max=10000"`
StackTrace string `json:"stack_trace,omitempty"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
URL string `json:"url" binding:"required"`
}
// LogError processes and stores frontend errors
func (h *ClientErrorHandler) LogError(c *gin.Context) {
var req LogErrorRequest
if err := c.ShouldBindJSON(&req); err != nil {
log.Printf("[ERROR] [server] [client_error] validation_failed error=\"%v\"", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request data"})
return
}
// Extract agent ID from auth middleware if available
var agentID interface{}
if agentIDValue, exists := c.Get("agentID"); exists {
if id, ok := agentIDValue.(uuid.UUID); ok {
agentID = id
}
}
// Log to console with HISTORY prefix
log.Printf("[ERROR] [server] [client] [%s] agent_id=%v subsystem=%s message=\"%s\"",
req.ErrorType, agentID, req.Subsystem, truncate(req.Message, 200))
log.Printf("[HISTORY] [server] [client_error] agent_id=%v subsystem=%s type=%s url=\"%s\" message=\"%s\" timestamp=%s",
agentID, req.Subsystem, req.ErrorType, req.URL, req.Message, time.Now().Format(time.RFC3339))
// Store in database with retry logic
if err := h.storeError(agentID, req, c); err != nil {
log.Printf("[ERROR] [server] [client_error] store_failed error=\"%v\"", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to store error"})
return
}
c.JSON(http.StatusOK, gin.H{"logged": true})
}
// storeError persists error to database with retry
func (h *ClientErrorHandler) storeError(agentID interface{}, req LogErrorRequest, c *gin.Context) error {
const maxRetries = 3
var lastErr error
for attempt := 1; attempt <= maxRetries; attempt++ {
query := `INSERT INTO client_errors (agent_id, subsystem, error_type, message, stack_trace, metadata, url, user_agent)
VALUES (:agent_id, :subsystem, :error_type, :message, :stack_trace, :metadata, :url, :user_agent)`
// Convert metadata map to JSON for PostgreSQL JSONB column
var metadataJSON json.RawMessage
if req.Metadata != nil && len(req.Metadata) > 0 {
jsonBytes, err := json.Marshal(req.Metadata)
if err != nil {
log.Printf("[ERROR] [server] [client_error] metadata_marshal_failed error=\"%v\"", err)
metadataJSON = nil
} else {
metadataJSON = json.RawMessage(jsonBytes)
}
}
_, err := h.db.NamedExec(query, map[string]interface{}{
"agent_id": agentID,
"subsystem": req.Subsystem,
"error_type": req.ErrorType,
"message": req.Message,
"stack_trace": req.StackTrace,
"metadata": metadataJSON,
"url": req.URL,
"user_agent": c.GetHeader("User-Agent"),
})
if err == nil {
return nil
}
lastErr = err
if attempt < maxRetries {
time.Sleep(time.Duration(attempt) * time.Second)
continue
}
}
return fmt.Errorf("failed after %d attempts: %w", maxRetries, lastErr)
}
func truncate(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
return s[:maxLen] + "..."
}