package api import ( "net/http" "strconv" "strings" "time" "github.com/ez-api/ez-api/internal/model" "github.com/gin-gonic/gin" ) 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 ListLogsResponse struct { Total int64 `json:"total"` Limit int `json:"limit"` Offset int `json:"offset"` Items []LogView `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 } // 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} ListLogsResponse // @Failure 500 {object} gin.H // @Router /admin/logs [get] func (h *Handler) ListLogs(c *gin.Context) { limit, offset := parseLimitOffset(c) q := h.db.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([]LogView, 0, len(rows)) for _, r := range rows { out = append(out, toLogView(r)) } c.JSON(http.StatusOK, ListLogsResponse{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"` } // 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.db.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} ListLogsResponse // @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 := h.db.Model(&model.LogRecord{}). Joins("JOIN keys ON keys.id = log_records.key_id"). Where("keys.master_id = ?", m.ID) 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([]LogView, 0, len(rows)) for _, r := range rows { out = append(out, toLogView(r)) } c.JSON(http.StatusOK, ListLogsResponse{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 := h.db.Model(&model.LogRecord{}). Joins("JOIN keys ON keys.id = log_records.key_id"). Where("keys.master_id = ?", m.ID) 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, }) }