Files
ez-api/internal/api/log_handler.go
zenfun c83fe03892 docs(swagger): update API documentation for log endpoints
Add new response types and parameters for logs API:
- Add GroupedStatsItem and GroupedStatsResponse definitions
- Add ListLogsResponse and LogView definitions for detailed log records
- Add group_by enum parameter (model/day/month) to stats endpoint
- Update endpoint descriptions to clarify response types and request_body inclusion
- Update response schema references to use correct types
2025-12-26 11:12:47 +08:00

646 lines
20 KiB
Go

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. Returns full log records including request_body.
// @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.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 (model/day/month). Without group_by returns LogStatsResponse; with group_by returns GroupedStatsResponse.
// @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. Returns GroupedStatsResponse when specified." Enums(model, day, month)
// @Success 200 {object} LogStatsResponse "Default aggregated stats (when group_by is not specified)"
// @Success 200 {object} GroupedStatsResponse "Grouped stats (when group_by is specified)"
// @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,
})
}