package api import ( "net/http" "strconv" "strings" "time" "github.com/ez-api/ez-api/internal/model" "github.com/gin-gonic/gin" "gorm.io/gorm" ) type LogView struct { ID uint `json:"id"` CreatedAt int64 `json:"created_at"` Group string `json:"group"` KeyID uint `json:"key_id"` ModelName string `json:"model"` ProviderID uint `json:"provider_id"` ProviderType string `json:"provider_type"` ProviderName string `json:"provider_name"` StatusCode int `json:"status_code"` LatencyMs int64 `json:"latency_ms"` TokensIn int64 `json:"tokens_in"` TokensOut int64 `json:"tokens_out"` ErrorMessage string `json:"error_message"` ClientIP string `json:"client_ip"` RequestSize int64 `json:"request_size"` ResponseSize int64 `json:"response_size"` AuditReason string `json:"audit_reason"` } func toLogView(r model.LogRecord) LogView { return LogView{ ID: r.ID, CreatedAt: r.CreatedAt.UTC().Unix(), Group: r.Group, KeyID: r.KeyID, ModelName: r.ModelName, ProviderID: r.ProviderID, ProviderType: r.ProviderType, ProviderName: r.ProviderName, StatusCode: r.StatusCode, LatencyMs: r.LatencyMs, TokensIn: r.TokensIn, TokensOut: r.TokensOut, ErrorMessage: r.ErrorMessage, ClientIP: r.ClientIP, RequestSize: r.RequestSize, ResponseSize: r.ResponseSize, AuditReason: r.AuditReason, } } type MasterLogView struct { ID uint `json:"id"` CreatedAt int64 `json:"created_at"` Group string `json:"group"` KeyID uint `json:"key_id"` ModelName string `json:"model"` StatusCode int `json:"status_code"` LatencyMs int64 `json:"latency_ms"` TokensIn int64 `json:"tokens_in"` TokensOut int64 `json:"tokens_out"` ErrorMessage string `json:"error_message"` RequestSize int64 `json:"request_size"` ResponseSize int64 `json:"response_size"` } func toMasterLogView(r model.LogRecord) MasterLogView { return MasterLogView{ ID: r.ID, CreatedAt: r.CreatedAt.UTC().Unix(), Group: r.Group, KeyID: r.KeyID, ModelName: r.ModelName, StatusCode: r.StatusCode, LatencyMs: r.LatencyMs, TokensIn: r.TokensIn, TokensOut: r.TokensOut, ErrorMessage: r.ErrorMessage, RequestSize: r.RequestSize, ResponseSize: r.ResponseSize, } } type ListLogsResponse struct { Total int64 `json:"total"` Limit int `json:"limit"` Offset int `json:"offset"` Items []LogView `json:"items"` } type ListMasterLogsResponse struct { Total int64 `json:"total"` Limit int `json:"limit"` Offset int `json:"offset"` Items []MasterLogView `json:"items"` } func parseLimitOffset(c *gin.Context) (limit, offset int) { limit = 50 offset = 0 if raw := strings.TrimSpace(c.Query("limit")); raw != "" { if v, err := strconv.Atoi(raw); err == nil && v > 0 { limit = v } } if limit > 200 { limit = 200 } if raw := strings.TrimSpace(c.Query("offset")); raw != "" { if v, err := strconv.Atoi(raw); err == nil && v >= 0 { offset = v } } return limit, offset } func parseUnixSeconds(raw string) (time.Time, bool) { raw = strings.TrimSpace(raw) if raw == "" { return time.Time{}, false } sec, err := strconv.ParseInt(raw, 10, 64) if err != nil || sec <= 0 { return time.Time{}, false } return time.Unix(sec, 0).UTC(), true } func (h *MasterHandler) masterLogBase(masterID uint) (*gorm.DB, error) { logDB := h.logDBConn() if logDB == h.db { return logDB.Model(&model.LogRecord{}). Joins("JOIN keys ON keys.id = log_records.key_id"). Where("keys.master_id = ?", masterID), nil } var keyIDs []uint if err := h.db.Model(&model.Key{}). Where("master_id = ?", masterID). Pluck("id", &keyIDs).Error; err != nil { return nil, err } if len(keyIDs) == 0 { return logDB.Model(&model.LogRecord{}).Where("1 = 0"), nil } return logDB.Model(&model.LogRecord{}). Where("log_records.key_id IN ?", keyIDs), nil } // ListLogs godoc // @Summary List logs (admin) // @Description List request logs with basic filtering/pagination // @Tags admin // @Produce json // @Security AdminAuth // @Param limit query int false "limit (default 50, max 200)" // @Param offset query int false "offset" // @Param since query int false "unix seconds" // @Param until query int false "unix seconds" // @Param key_id query int false "key id" // @Param group query string false "route group" // @Param model query string false "model" // @Param status_code query int false "status code" // @Success 200 {object} ListMasterLogsResponse // @Failure 500 {object} gin.H // @Router /admin/logs [get] func (h *Handler) ListLogs(c *gin.Context) { limit, offset := parseLimitOffset(c) q := h.logDBConn().Model(&model.LogRecord{}) if t, ok := parseUnixSeconds(c.Query("since")); ok { q = q.Where("created_at >= ?", t) } if t, ok := parseUnixSeconds(c.Query("until")); ok { q = q.Where("created_at <= ?", t) } if raw := strings.TrimSpace(c.Query("key_id")); raw != "" { if v, err := strconv.ParseUint(raw, 10, 64); err == nil && v > 0 { q = q.Where("key_id = ?", uint(v)) } } if raw := strings.TrimSpace(c.Query("group")); raw != "" { q = q.Where(`"group" = ?`, raw) } if raw := strings.TrimSpace(c.Query("model")); raw != "" { q = q.Where("model_name = ?", raw) } if raw := strings.TrimSpace(c.Query("status_code")); raw != "" { if v, err := strconv.Atoi(raw); err == nil && v > 0 { q = q.Where("status_code = ?", v) } } var total int64 if err := q.Count(&total).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to count logs", "details": err.Error()}) return } var rows []model.LogRecord if err := q.Order("id desc").Limit(limit).Offset(offset).Find(&rows).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list logs", "details": err.Error()}) return } out := make([]MasterLogView, 0, len(rows)) for _, r := range rows { out = append(out, toMasterLogView(r)) } c.JSON(http.StatusOK, ListMasterLogsResponse{Total: total, Limit: limit, Offset: offset, Items: out}) } type LogStatsResponse struct { Total int64 `json:"total"` TokensIn int64 `json:"tokens_in"` TokensOut int64 `json:"tokens_out"` AvgLatency float64 `json:"avg_latency_ms"` ByStatus map[string]int64 `json:"by_status"` } type DeleteLogsRequest struct { Before string `json:"before"` KeyID uint `json:"key_id"` Model string `json:"model"` } type DeleteLogsResponse struct { DeletedCount int64 `json:"deleted_count"` } // DeleteLogs godoc // @Summary Delete logs (admin) // @Description Delete logs before a given timestamp with optional filters // @Tags admin // @Accept json // @Produce json // @Security AdminAuth // @Param request body DeleteLogsRequest true "Delete filters" // @Success 200 {object} DeleteLogsResponse // @Failure 400 {object} gin.H // @Failure 500 {object} gin.H // @Router /admin/logs [delete] func (h *Handler) DeleteLogs(c *gin.Context) { var req DeleteLogsRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) return } before := strings.TrimSpace(req.Before) if before == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "before is required"}) return } ts, err := time.Parse(time.RFC3339, before) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid before time"}) return } q := h.logDBConn().Unscoped().Where("created_at < ?", ts.UTC()) if req.KeyID > 0 { q = q.Where("key_id = ?", req.KeyID) } if model := strings.TrimSpace(req.Model); model != "" { q = q.Where("model_name = ?", model) } res := q.Delete(&model.LogRecord{}) if res.Error != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete logs", "details": res.Error.Error()}) return } c.JSON(http.StatusOK, DeleteLogsResponse{DeletedCount: res.RowsAffected}) } // LogStats godoc // @Summary Log stats (admin) // @Description Aggregate log stats with basic filtering // @Tags admin // @Produce json // @Security AdminAuth // @Param since query int false "unix seconds" // @Param until query int false "unix seconds" // @Success 200 {object} LogStatsResponse // @Failure 500 {object} gin.H // @Router /admin/logs/stats [get] func (h *Handler) LogStats(c *gin.Context) { q := h.logDBConn().Model(&model.LogRecord{}) if t, ok := parseUnixSeconds(c.Query("since")); ok { q = q.Where("created_at >= ?", t) } if t, ok := parseUnixSeconds(c.Query("until")); ok { q = q.Where("created_at <= ?", t) } var total int64 if err := q.Count(&total).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to count logs", "details": err.Error()}) return } type sums struct { TokensIn int64 TokensOut int64 AvgLatency float64 } var s sums if err := q.Select("COALESCE(SUM(tokens_in),0) as tokens_in, COALESCE(SUM(tokens_out),0) as tokens_out, COALESCE(AVG(latency_ms),0) as avg_latency").Scan(&s).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to aggregate logs", "details": err.Error()}) return } type bucket struct { StatusCode int Cnt int64 } var buckets []bucket if err := q.Select("status_code, COUNT(*) as cnt").Group("status_code").Scan(&buckets).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to bucket logs", "details": err.Error()}) return } byStatus := make(map[string]int64, len(buckets)) for _, b := range buckets { byStatus[strconv.Itoa(b.StatusCode)] = b.Cnt } c.JSON(http.StatusOK, LogStatsResponse{ Total: total, TokensIn: s.TokensIn, TokensOut: s.TokensOut, AvgLatency: s.AvgLatency, ByStatus: byStatus, }) } // ListSelfLogs godoc // @Summary List logs (master) // @Description List request logs for the authenticated master // @Tags master // @Produce json // @Security MasterAuth // @Param limit query int false "limit (default 50, max 200)" // @Param offset query int false "offset" // @Param since query int false "unix seconds" // @Param until query int false "unix seconds" // @Param model query string false "model" // @Param status_code query int false "status code" // @Success 200 {object} ListMasterLogsResponse // @Failure 401 {object} gin.H // @Failure 500 {object} gin.H // @Router /v1/logs [get] func (h *MasterHandler) ListSelfLogs(c *gin.Context) { master, exists := c.Get("master") if !exists { c.JSON(http.StatusUnauthorized, gin.H{"error": "master key not found in context"}) return } m := master.(*model.Master) limit, offset := parseLimitOffset(c) q, err := h.masterLogBase(m.ID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to build log query", "details": err.Error()}) return } if t, ok := parseUnixSeconds(c.Query("since")); ok { q = q.Where("log_records.created_at >= ?", t) } if t, ok := parseUnixSeconds(c.Query("until")); ok { q = q.Where("log_records.created_at <= ?", t) } if raw := strings.TrimSpace(c.Query("model")); raw != "" { q = q.Where("log_records.model_name = ?", raw) } if raw := strings.TrimSpace(c.Query("status_code")); raw != "" { if v, err := strconv.Atoi(raw); err == nil && v > 0 { q = q.Where("log_records.status_code = ?", v) } } var total int64 if err := q.Count(&total).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to count logs", "details": err.Error()}) return } var rows []model.LogRecord if err := q.Order("log_records.id desc").Limit(limit).Offset(offset).Find(&rows).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list logs", "details": err.Error()}) return } out := make([]MasterLogView, 0, len(rows)) for _, r := range rows { out = append(out, toMasterLogView(r)) } c.JSON(http.StatusOK, ListMasterLogsResponse{Total: total, Limit: limit, Offset: offset, Items: out}) } // GetSelfLogStats godoc // @Summary Log stats (master) // @Description Aggregate request log stats for the authenticated master // @Tags master // @Produce json // @Security MasterAuth // @Param since query int false "unix seconds" // @Param until query int false "unix seconds" // @Success 200 {object} LogStatsResponse // @Failure 401 {object} gin.H // @Failure 500 {object} gin.H // @Router /v1/logs/stats [get] func (h *MasterHandler) GetSelfLogStats(c *gin.Context) { master, exists := c.Get("master") if !exists { c.JSON(http.StatusUnauthorized, gin.H{"error": "master key not found in context"}) return } m := master.(*model.Master) q, err := h.masterLogBase(m.ID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to build log query", "details": err.Error()}) return } if t, ok := parseUnixSeconds(c.Query("since")); ok { q = q.Where("log_records.created_at >= ?", t) } if t, ok := parseUnixSeconds(c.Query("until")); ok { q = q.Where("log_records.created_at <= ?", t) } var total int64 if err := q.Count(&total).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to count logs", "details": err.Error()}) return } type sums struct { TokensIn int64 TokensOut int64 AvgLatency float64 } var s sums if err := q.Select("COALESCE(SUM(log_records.tokens_in),0) as tokens_in, COALESCE(SUM(log_records.tokens_out),0) as tokens_out, COALESCE(AVG(log_records.latency_ms),0) as avg_latency").Scan(&s).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to aggregate logs", "details": err.Error()}) return } type bucket struct { StatusCode int Cnt int64 } var buckets []bucket if err := q.Select("log_records.status_code as status_code, COUNT(*) as cnt").Group("log_records.status_code").Scan(&buckets).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to bucket logs", "details": err.Error()}) return } byStatus := make(map[string]int64, len(buckets)) for _, b := range buckets { byStatus[strconv.Itoa(b.StatusCode)] = b.Cnt } c.JSON(http.StatusOK, LogStatsResponse{ Total: total, TokensIn: s.TokensIn, TokensOut: s.TokensOut, AvgLatency: s.AvgLatency, ByStatus: byStatus, }) }