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] + "..." }