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"` RequestBody string `json:"request_body,omitempty"` } 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, RequestBody: r.RequestBody, } } 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() base := h.logBaseQuery() if logDB == h.db { return base. 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 base.Where("1 = 0"), nil } return base. 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.logBaseQuery() 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 } // Admin sees full LogView including request_body 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"` } // GroupedStatsItem represents a single group in grouped statistics type GroupedStatsItem struct { // For group_by=model Model string `json:"model,omitempty"` // For group_by=day Date string `json:"date,omitempty"` // For group_by=month Month string `json:"month,omitempty"` Count int64 `json:"count"` TokensIn int64 `json:"tokens_in"` TokensOut int64 `json:"tokens_out"` AvgLatencyMs float64 `json:"avg_latency_ms,omitempty"` } // GroupedStatsResponse is returned when group_by is specified type GroupedStatsResponse struct { Items []GroupedStatsItem `json:"items"` } 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 } deleted, err := h.deleteLogsBefore(ts.UTC(), req.KeyID, req.Model) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete logs", "details": err.Error()}) return } c.JSON(http.StatusOK, DeleteLogsResponse{DeletedCount: deleted}) } func (h *Handler) deleteLogsBefore(cutoff time.Time, keyID uint, modelName string) (int64, error) { modelName = strings.TrimSpace(modelName) if h == nil || h.logPartitioner == nil || !h.logPartitioner.Enabled() { q := h.logBaseQuery().Unscoped().Where("created_at < ?", cutoff) if keyID > 0 { q = q.Where("key_id = ?", keyID) } if modelName != "" { q = q.Where("model_name = ?", modelName) } res := q.Delete(&model.LogRecord{}) return res.RowsAffected, res.Error } partitions, err := h.logPartitioner.ListPartitions() if err != nil { return 0, err } if len(partitions) == 0 { q := h.logDBConn().Table("log_records").Unscoped().Where("created_at < ?", cutoff) if keyID > 0 { q = q.Where("key_id = ?", keyID) } if modelName != "" { q = q.Where("model_name = ?", modelName) } res := q.Delete(&model.LogRecord{}) return res.RowsAffected, res.Error } var deleted int64 for _, part := range partitions { if !part.Start.Before(cutoff) { continue } q := h.logDBConn().Table(part.Table).Unscoped().Where("created_at < ?", cutoff) if keyID > 0 { q = q.Where("key_id = ?", keyID) } if modelName != "" { q = q.Where("model_name = ?", modelName) } res := q.Delete(&model.LogRecord{}) if res.Error != nil { return deleted, res.Error } deleted += res.RowsAffected } return deleted, nil } // LogStats godoc // @Summary Log stats (admin) // @Description Aggregate log stats with basic filtering. Use group_by param for grouped statistics. // @Tags admin // @Produce json // @Security AdminAuth // @Param since query int false "unix seconds" // @Param until query int false "unix seconds" // @Param group_by query string false "group by dimension: model, day, month" // @Success 200 {object} LogStatsResponse // @Failure 500 {object} gin.H // @Router /admin/logs/stats [get] func (h *Handler) LogStats(c *gin.Context) { q := h.logBaseQuery() 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) } groupBy := strings.TrimSpace(c.Query("group_by")) switch groupBy { case "model": h.logStatsByModel(c, q) return case "day": h.logStatsByDay(c, q) return case "month": h.logStatsByMonth(c, q) return } // Default: aggregated stats (backward compatible) 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, }) } // logStatsByModel handles group_by=model func (h *Handler) logStatsByModel(c *gin.Context, q *gorm.DB) { type modelStats struct { ModelName string Cnt int64 TokensIn int64 TokensOut int64 AvgLatencyMs float64 } var rows []modelStats if err := q.Select(`model_name, COUNT(*) as cnt, COALESCE(SUM(tokens_in),0) as tokens_in, COALESCE(SUM(tokens_out),0) as tokens_out, COALESCE(AVG(latency_ms),0) as avg_latency_ms`). Group("model_name"). Order("cnt DESC"). Scan(&rows).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to aggregate by model", "details": err.Error()}) return } items := make([]GroupedStatsItem, 0, len(rows)) for _, r := range rows { items = append(items, GroupedStatsItem{ Model: r.ModelName, Count: r.Cnt, TokensIn: r.TokensIn, TokensOut: r.TokensOut, AvgLatencyMs: r.AvgLatencyMs, }) } c.JSON(http.StatusOK, GroupedStatsResponse{Items: items}) } // logStatsByDay handles group_by=day func (h *Handler) logStatsByDay(c *gin.Context, q *gorm.DB) { type dayStats struct { Day string Cnt int64 TokensIn int64 TokensOut int64 } var rows []dayStats // PostgreSQL DATE function; SQLite uses date() if err := q.Select(`TO_CHAR(created_at, 'YYYY-MM-DD') as day, COUNT(*) as cnt, COALESCE(SUM(tokens_in),0) as tokens_in, COALESCE(SUM(tokens_out),0) as tokens_out`). Group("day"). Order("day ASC"). Scan(&rows).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to aggregate by day", "details": err.Error()}) return } items := make([]GroupedStatsItem, 0, len(rows)) for _, r := range rows { items = append(items, GroupedStatsItem{ Date: r.Day, Count: r.Cnt, TokensIn: r.TokensIn, TokensOut: r.TokensOut, }) } c.JSON(http.StatusOK, GroupedStatsResponse{Items: items}) } // logStatsByMonth handles group_by=month func (h *Handler) logStatsByMonth(c *gin.Context, q *gorm.DB) { type monthStats struct { Month string Cnt int64 TokensIn int64 TokensOut int64 } var rows []monthStats // PostgreSQL format if err := q.Select(`TO_CHAR(created_at, 'YYYY-MM') as month, COUNT(*) as cnt, COALESCE(SUM(tokens_in),0) as tokens_in, COALESCE(SUM(tokens_out),0) as tokens_out`). Group("month"). Order("month ASC"). Scan(&rows).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to aggregate by month", "details": err.Error()}) return } items := make([]GroupedStatsItem, 0, len(rows)) for _, r := range rows { items = append(items, GroupedStatsItem{ Month: r.Month, Count: r.Cnt, TokensIn: r.TokensIn, TokensOut: r.TokensOut, }) } c.JSON(http.StatusOK, GroupedStatsResponse{Items: items}) } // 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, }) }