Files
MyClub/internal/controllers/error_controller.go
T
Tomas Dvorak f3db65d350 dev day #90 🥳
2025-11-12 20:31:37 +01:00

288 lines
11 KiB
Go

package controllers
import (
"bytes"
"encoding/json"
"io"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
"fotbal-club/internal/models"
"fotbal-club/internal/config"
"github.com/gin-gonic/gin"
"gorm.io/datatypes"
"gorm.io/gorm"
)
type ErrorController struct {
DB *gorm.DB
}
func NewErrorController(db *gorm.DB) *ErrorController { return &ErrorController{DB: db} }
type ingestPayload struct {
Origin string `json:"origin"`
Language string `json:"language"`
Severity string `json:"severity"`
Message string `json:"message"`
Stack string `json:"stack"`
Component string `json:"component"`
File string `json:"file"`
Line int `json:"line"`
Column int `json:"column"`
URL string `json:"url"`
Method string `json:"method"`
Status int `json:"status"`
RequestID string `json:"request_id"`
UserID *uint `json:"user_id"`
SessionToken string `json:"session_token"`
Tags map[string]string `json:"tags"`
Context map[string]interface{} `json:"context"`
Env string `json:"env"`
Version string `json:"version"`
Hostname string `json:"hostname"`
OccurredAt *time.Time `json:"occurred_at"`
}
func (ec *ErrorController) Ingest(c *gin.Context) {
var p ingestPayload
if err := c.ShouldBindJSON(&p); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
return
}
var s models.Settings
_ = ec.DB.First(&s).Error
if p.Tags == nil { p.Tags = map[string]string{} }
if strings.TrimSpace(p.Origin) != "" {
if _, ok := p.Tags["service"]; !ok { p.Tags["service"] = strings.TrimSpace(p.Origin) }
} else {
if _, ok := p.Tags["service"]; !ok { p.Tags["service"] = "backend" }
}
if _, ok := p.Tags["instance_env"]; !ok {
if config.AppConfig != nil { p.Tags["instance_env"] = strings.TrimSpace(config.AppConfig.AppEnv) }
}
if p.Hostname == "" {
host := c.Request.Host
if idx := strings.Index(host, ":"); idx >= 0 { host = host[:idx] }
p.Hostname = host
}
if _, ok := p.Tags["instance_host"]; !ok && strings.TrimSpace(p.Hostname) != "" {
p.Tags["instance_host"] = strings.TrimSpace(p.Hostname)
}
if v := strings.TrimSpace(s.ClubName); v != "" { p.Tags["club_name"] = v }
if v := strings.TrimSpace(s.ClubID); v != "" { p.Tags["club_id"] = v }
if v := strings.TrimSpace(s.ClubLogoURL); v != "" { p.Tags["club_logo_url"] = v }
if p.Env == "" && config.AppConfig != nil { p.Env = config.AppConfig.AppEnv }
if p.Version == "" {
v := strings.TrimSpace(os.Getenv("APP_VERSION"))
if v != "" { p.Version = v }
}
if _, ok := p.Tags["container_id"]; !ok {
if h, err := os.Hostname(); err == nil {
if hv := strings.TrimSpace(h); hv != "" { p.Tags["container_id"] = hv }
}
}
var tags datatypes.JSON
var ctx datatypes.JSON
if p.Tags != nil {
if b, err := json.Marshal(p.Tags); err == nil { tags = datatypes.JSON(b) }
}
if p.Context != nil {
if b, err := json.Marshal(p.Context); err == nil { ctx = datatypes.JSON(b) }
}
occurred := time.Now()
if p.OccurredAt != nil && !p.OccurredAt.IsZero() {
occurred = *p.OccurredAt
}
rec := models.ErrorEvent{
Origin: p.Origin,
Language: p.Language,
Severity: p.Severity,
Message: p.Message,
Stack: p.Stack,
Component: p.Component,
File: p.File,
Line: p.Line,
Column: p.Column,
URL: p.URL,
Method: p.Method,
Status: p.Status,
RequestID: p.RequestID,
UserID: p.UserID,
SessionToken: p.SessionToken,
Tags: tags,
Context: ctx,
Env: p.Env,
Version: p.Version,
Hostname: p.Hostname,
OccurredAt: occurred,
}
if err := ec.DB.Create(&rec).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to store"})
return
}
go func(p ingestPayload) {
var s models.Settings
_ = ec.DB.First(&s).Error
// Prefer environment (config) for ingest URL and token; domain managed solely via .env
extURL := strings.TrimSpace(config.AppConfig.ErrorIngestURL)
token := strings.TrimSpace(config.AppConfig.ErrorIngestToken)
if extURL == "" {
// Default ingest URL: local when ERROR_LOCAL, otherwise external
errorLocal := false
if b, err := strconv.ParseBool(strings.TrimSpace(os.Getenv("ERROR_LOCAL"))); err == nil && b { errorLocal = true }
if !errorLocal {
if b, err := strconv.ParseBool(strings.TrimSpace(os.Getenv("error_local"))); err == nil && b { errorLocal = true }
}
if errorLocal { extURL = "http://127.0.0.1:8083/api/v1/errors" } else { extURL = "https://errors.tdvorak.dev/api/v1/errors" }
}
if extURL == "" { return }
b, err := json.Marshal(p)
if err != nil { return }
post := func(u string) bool {
req, err := http.NewRequest(http.MethodPost, u, bytes.NewReader(b))
if err != nil { return false }
req.Header.Set("Content-Type", "application/json")
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("X-Ingest-Token", token)
}
client := &http.Client{ Timeout: 4 * time.Second }
resp, err := client.Do(req)
if err != nil { return false }
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
return resp.StatusCode >= 200 && resp.StatusCode < 300
}
if post(extURL) { return }
if u, err := url.Parse(extURL); err == nil {
h := u.Hostname()
if h == "127.0.0.1" || h == "localhost" {
u.Host = strings.Replace(u.Host, h, "host.docker.internal", 1)
_ = post(u.String())
}
}
}(p)
c.JSON(http.StatusCreated, gin.H{"id": rec.ID})
}
type listResponse struct {
Items []models.ErrorEvent `json:"items"`
Total int64 `json:"total"`
}
func (ec *ErrorController) AdminList(c *gin.Context) {
q := ec.DB.Model(&models.ErrorEvent{})
if v := strings.TrimSpace(c.Query("origin")); v != "" { q = q.Where("origin = ?", v) }
if v := strings.TrimSpace(c.Query("severity")); v != "" { q = q.Where("severity = ?", v) }
if v := strings.TrimSpace(c.Query("method")); v != "" { q = q.Where("method = ?", v) }
if v := c.Query("status"); v != "" { q = q.Where("status = ?", v) }
if v := strings.TrimSpace(c.Query("search")); v != "" {
like := "%" + v + "%"
q = q.Where("message ILIKE ? OR stack ILIKE ? OR url ILIKE ?", like, like, like)
}
if v := c.Query("from"); v != "" {
if t, err := time.Parse(time.RFC3339, v); err == nil { q = q.Where("occurred_at >= ?", t) }
}
if v := c.Query("to"); v != "" {
if t, err := time.Parse(time.RFC3339, v); err == nil { q = q.Where("occurred_at <= ?", t) }
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
if page < 1 { page = 1 }
if limit < 1 || limit > 200 { limit = 20 }
var total int64
_ = q.Count(&total).Error
var items []models.ErrorEvent
_ = q.Order("occurred_at DESC").Limit(limit).Offset((page-1)*limit).Find(&items).Error
c.JSON(http.StatusOK, listResponse{Items: items, Total: total})
}
func (ec *ErrorController) AdminGet(c *gin.Context) {
var rec models.ErrorEvent
if err := ec.DB.First(&rec, c.Param("id")).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load"})
return
}
c.JSON(http.StatusOK, rec)
}
// AdminListExternal proxies list to external error-review admin API
func (ec *ErrorController) AdminListExternal(c *gin.Context) {
var s models.Settings
_ = ec.DB.First(&s).Error
// Domain managed via environment only (with default); token from env
base := strings.TrimSpace(os.Getenv("ERROR_REVIEW_ADMIN_URL"))
if base == "" {
errorLocal := false
if b, err := strconv.ParseBool(strings.TrimSpace(os.Getenv("ERROR_LOCAL"))); err == nil && b { errorLocal = true }
if !errorLocal {
if b, err := strconv.ParseBool(strings.TrimSpace(os.Getenv("error_local"))); err == nil && b { errorLocal = true }
}
if errorLocal { base = "http://127.0.0.1:8083/api/v1/admin" } else { base = "https://errors.tdvorak.dev/api/v1/admin" }
}
token := strings.TrimSpace(os.Getenv("ERROR_REVIEW_ADMIN_TOKEN"))
u, err := url.Parse(base)
if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid admin URL"}); return }
u.Path = strings.TrimRight(u.Path, "/") + "/errors"
u.RawQuery = c.Request.URL.RawQuery
req, err := http.NewRequest(http.MethodGet, u.String(), nil)
if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "request build failed"}); return }
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("X-Admin-Token", token)
}
req.Header.Set("Accept", "application/json")
client := &http.Client{ Timeout: 8 * time.Second }
resp, err := client.Do(req)
if err != nil { c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}); return }
defer resp.Body.Close()
c.Status(resp.StatusCode)
c.Header("Content-Type", resp.Header.Get("Content-Type"))
io.Copy(c.Writer, resp.Body)
}
// AdminGetExternal proxies detail to external error-review admin API
func (ec *ErrorController) AdminGetExternal(c *gin.Context) {
var s models.Settings
_ = ec.DB.First(&s).Error
// Domain managed via environment only (with default); token from env
base := strings.TrimSpace(os.Getenv("ERROR_REVIEW_ADMIN_URL"))
if base == "" {
errorLocal := false
if b, err := strconv.ParseBool(strings.TrimSpace(os.Getenv("ERROR_LOCAL"))); err == nil && b { errorLocal = true }
if !errorLocal {
if b, err := strconv.ParseBool(strings.TrimSpace(os.Getenv("error_local"))); err == nil && b { errorLocal = true }
}
if errorLocal { base = "http://127.0.0.1:8083/api/v1/admin" } else { base = "https://errors.tdvorak.dev/api/v1/admin" }
}
token := strings.TrimSpace(os.Getenv("ERROR_REVIEW_ADMIN_TOKEN"))
u, err := url.Parse(base)
if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid admin URL"}); return }
id := strings.TrimSpace(c.Param("id"))
u.Path = strings.TrimRight(u.Path, "/") + "/errors/" + id
req, err := http.NewRequest(http.MethodGet, u.String(), nil)
if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "request build failed"}); return }
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("X-Admin-Token", token)
}
req.Header.Set("Accept", "application/json")
client := &http.Client{ Timeout: 8 * time.Second }
resp, err := client.Do(req)
if err != nil { c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}); return }
defer resp.Body.Close()
c.Status(resp.StatusCode)
c.Header("Content-Type", resp.Header.Get("Content-Type"))
io.Copy(c.Writer, resp.Body)
}