feat(api): add admin traffic chart statistics endpoint

Add new endpoint GET /admin/logs/stats/traffic-chart to provide
aggregated traffic metrics grouped by time and model. Features include:
- Time granularity selection (hour/minute)
- Top-N model breakdown with "other" aggregation
- Metrics for request counts and token usage

Includes generated Swagger documentation.
This commit is contained in:
zenfun
2026-01-02 21:24:56 +08:00
parent bae3d9bd5b
commit 9d082ff375
5 changed files with 555 additions and 0 deletions

View File

@@ -368,6 +368,7 @@ func main() {
adminGroup.GET("/logs", handler.ListLogs) adminGroup.GET("/logs", handler.ListLogs)
adminGroup.DELETE("/logs", handler.DeleteLogs) adminGroup.DELETE("/logs", handler.DeleteLogs)
adminGroup.GET("/logs/stats", handler.LogStats) adminGroup.GET("/logs/stats", handler.LogStats)
adminGroup.GET("/logs/stats/traffic-chart", handler.GetTrafficChart)
adminGroup.GET("/logs/webhook", handler.GetLogWebhookConfig) adminGroup.GET("/logs/webhook", handler.GetLogWebhookConfig)
adminGroup.PUT("/logs/webhook", handler.UpdateLogWebhookConfig) adminGroup.PUT("/logs/webhook", handler.UpdateLogWebhookConfig)
adminGroup.GET("/stats", adminHandler.GetAdminStats) adminGroup.GET("/stats", adminHandler.GetAdminStats)

View File

@@ -1610,6 +1610,73 @@ const docTemplate = `{
} }
} }
}, },
"/admin/logs/stats/traffic-chart": {
"get": {
"security": [
{
"AdminAuth": []
}
],
"description": "Get time × model aggregated data for stacked traffic charts. Returns time buckets with per-model breakdown.",
"produces": [
"application/json"
],
"tags": [
"admin"
],
"summary": "Traffic chart data (admin)",
"parameters": [
{
"enum": [
"hour",
"minute"
],
"type": "string",
"description": "Time granularity: hour (default) or minute",
"name": "granularity",
"in": "query"
},
{
"type": "integer",
"description": "Start time (unix seconds), defaults to 24h ago",
"name": "since",
"in": "query"
},
{
"type": "integer",
"description": "End time (unix seconds), defaults to now",
"name": "until",
"in": "query"
},
{
"type": "integer",
"description": "Number of top models to return (1-20), defaults to 5",
"name": "top_n",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/internal_api.TrafficChartResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/gin.H"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/gin.H"
}
}
}
}
},
"/admin/logs/webhook": { "/admin/logs/webhook": {
"get": { "get": {
"security": [ "security": [
@@ -5840,6 +5907,66 @@ const docTemplate = `{
} }
} }
}, },
"internal_api.TrafficBucket": {
"type": "object",
"properties": {
"breakdown": {
"type": "object",
"additionalProperties": {
"$ref": "#/definitions/internal_api.TrafficMetrics"
}
},
"time": {
"type": "string"
},
"timestamp": {
"type": "integer"
},
"total": {
"$ref": "#/definitions/internal_api.TrafficMetrics"
}
}
},
"internal_api.TrafficChartResponse": {
"type": "object",
"properties": {
"buckets": {
"type": "array",
"items": {
"$ref": "#/definitions/internal_api.TrafficBucket"
}
},
"granularity": {
"type": "string"
},
"models": {
"type": "array",
"items": {
"type": "string"
}
},
"since": {
"type": "integer"
},
"until": {
"type": "integer"
}
}
},
"internal_api.TrafficMetrics": {
"type": "object",
"properties": {
"count": {
"type": "integer"
},
"tokens_in": {
"type": "integer"
},
"tokens_out": {
"type": "integer"
}
}
},
"internal_api.UpdateAccessRequest": { "internal_api.UpdateAccessRequest": {
"type": "object", "type": "object",
"properties": { "properties": {

View File

@@ -1604,6 +1604,73 @@
} }
} }
}, },
"/admin/logs/stats/traffic-chart": {
"get": {
"security": [
{
"AdminAuth": []
}
],
"description": "Get time × model aggregated data for stacked traffic charts. Returns time buckets with per-model breakdown.",
"produces": [
"application/json"
],
"tags": [
"admin"
],
"summary": "Traffic chart data (admin)",
"parameters": [
{
"enum": [
"hour",
"minute"
],
"type": "string",
"description": "Time granularity: hour (default) or minute",
"name": "granularity",
"in": "query"
},
{
"type": "integer",
"description": "Start time (unix seconds), defaults to 24h ago",
"name": "since",
"in": "query"
},
{
"type": "integer",
"description": "End time (unix seconds), defaults to now",
"name": "until",
"in": "query"
},
{
"type": "integer",
"description": "Number of top models to return (1-20), defaults to 5",
"name": "top_n",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/internal_api.TrafficChartResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/gin.H"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/gin.H"
}
}
}
}
},
"/admin/logs/webhook": { "/admin/logs/webhook": {
"get": { "get": {
"security": [ "security": [
@@ -5834,6 +5901,66 @@
} }
} }
}, },
"internal_api.TrafficBucket": {
"type": "object",
"properties": {
"breakdown": {
"type": "object",
"additionalProperties": {
"$ref": "#/definitions/internal_api.TrafficMetrics"
}
},
"time": {
"type": "string"
},
"timestamp": {
"type": "integer"
},
"total": {
"$ref": "#/definitions/internal_api.TrafficMetrics"
}
}
},
"internal_api.TrafficChartResponse": {
"type": "object",
"properties": {
"buckets": {
"type": "array",
"items": {
"$ref": "#/definitions/internal_api.TrafficBucket"
}
},
"granularity": {
"type": "string"
},
"models": {
"type": "array",
"items": {
"type": "string"
}
},
"since": {
"type": "integer"
},
"until": {
"type": "integer"
}
}
},
"internal_api.TrafficMetrics": {
"type": "object",
"properties": {
"count": {
"type": "integer"
},
"tokens_in": {
"type": "integer"
},
"tokens_out": {
"type": "integer"
}
}
},
"internal_api.UpdateAccessRequest": { "internal_api.UpdateAccessRequest": {
"type": "object", "type": "object",
"properties": { "properties": {

View File

@@ -1055,6 +1055,45 @@ definitions:
tokens: tokens:
type: integer type: integer
type: object type: object
internal_api.TrafficBucket:
properties:
breakdown:
additionalProperties:
$ref: '#/definitions/internal_api.TrafficMetrics'
type: object
time:
type: string
timestamp:
type: integer
total:
$ref: '#/definitions/internal_api.TrafficMetrics'
type: object
internal_api.TrafficChartResponse:
properties:
buckets:
items:
$ref: '#/definitions/internal_api.TrafficBucket'
type: array
granularity:
type: string
models:
items:
type: string
type: array
since:
type: integer
until:
type: integer
type: object
internal_api.TrafficMetrics:
properties:
count:
type: integer
tokens_in:
type: integer
tokens_out:
type: integer
type: object
internal_api.UpdateAccessRequest: internal_api.UpdateAccessRequest:
properties: properties:
default_namespace: default_namespace:
@@ -2333,6 +2372,50 @@ paths:
summary: Log stats (admin) summary: Log stats (admin)
tags: tags:
- admin - admin
/admin/logs/stats/traffic-chart:
get:
description: Get time × model aggregated data for stacked traffic charts. Returns
time buckets with per-model breakdown.
parameters:
- description: 'Time granularity: hour (default) or minute'
enum:
- hour
- minute
in: query
name: granularity
type: string
- description: Start time (unix seconds), defaults to 24h ago
in: query
name: since
type: integer
- description: End time (unix seconds), defaults to now
in: query
name: until
type: integer
- description: Number of top models to return (1-20), defaults to 5
in: query
name: top_n
type: integer
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/internal_api.TrafficChartResponse'
"400":
description: Bad Request
schema:
$ref: '#/definitions/gin.H'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/gin.H'
security:
- AdminAuth: []
summary: Traffic chart data (admin)
tags:
- admin
/admin/logs/webhook: /admin/logs/webhook:
get: get:
description: Returns current webhook notification config description: Returns current webhook notification config

View File

@@ -609,6 +609,223 @@ func (h *Handler) logStatsByMinute(c *gin.Context, q *gorm.DB, sinceTime, untilT
c.JSON(http.StatusOK, GroupedStatsResponse{Items: items}) c.JSON(http.StatusOK, GroupedStatsResponse{Items: items})
} }
// TrafficMetrics contains the metrics for a model in a bucket
type TrafficMetrics struct {
Count int64 `json:"count"`
TokensIn int64 `json:"tokens_in"`
TokensOut int64 `json:"tokens_out"`
}
// TrafficBucket represents one time bucket with model breakdown
type TrafficBucket struct {
Time string `json:"time"`
Timestamp int64 `json:"timestamp"`
Breakdown map[string]TrafficMetrics `json:"breakdown"`
Total TrafficMetrics `json:"total"`
}
// TrafficChartResponse is the response for traffic chart API
type TrafficChartResponse struct {
Granularity string `json:"granularity"`
Since int64 `json:"since"`
Until int64 `json:"until"`
Models []string `json:"models"`
Buckets []TrafficBucket `json:"buckets"`
}
const (
defaultTrafficTopN = 5
maxTrafficTopN = 20
)
// GetTrafficChart godoc
// @Summary Traffic chart data (admin)
// @Description Get time × model aggregated data for stacked traffic charts. Returns time buckets with per-model breakdown.
// @Tags admin
// @Produce json
// @Security AdminAuth
// @Param granularity query string false "Time granularity: hour (default) or minute" Enums(hour, minute)
// @Param since query int false "Start time (unix seconds), defaults to 24h ago"
// @Param until query int false "End time (unix seconds), defaults to now"
// @Param top_n query int false "Number of top models to return (1-20), defaults to 5"
// @Success 200 {object} TrafficChartResponse
// @Failure 400 {object} gin.H
// @Failure 500 {object} gin.H
// @Router /admin/logs/stats/traffic-chart [get]
func (h *Handler) GetTrafficChart(c *gin.Context) {
// Parse granularity
granularity := strings.TrimSpace(c.Query("granularity"))
if granularity == "" {
granularity = "hour"
}
if granularity != "hour" && granularity != "minute" {
c.JSON(http.StatusBadRequest, gin.H{"error": "granularity must be 'hour' or 'minute'"})
return
}
// Parse time range
now := time.Now().UTC()
var sinceTime, untilTime time.Time
if t, ok := parseUnixSeconds(c.Query("since")); ok {
sinceTime = t
} else {
sinceTime = now.Add(-24 * time.Hour)
}
if t, ok := parseUnixSeconds(c.Query("until")); ok {
untilTime = t
} else {
untilTime = now
}
// Validate time range for minute granularity
if granularity == "minute" {
if c.Query("since") == "" || c.Query("until") == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "minute-level aggregation requires both 'since' and 'until' parameters"})
return
}
duration := untilTime.Sub(sinceTime)
if duration > maxMinuteRangeDuration {
c.JSON(http.StatusBadRequest, gin.H{
"error": "time range too large for minute granularity",
"max_hours": 6,
"actual_hours": duration.Hours(),
})
return
}
if duration < 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "'until' must be after 'since'"})
return
}
}
// Parse top_n
topN := defaultTrafficTopN
if raw := strings.TrimSpace(c.Query("top_n")); raw != "" {
if v, err := strconv.Atoi(raw); err == nil && v > 0 {
topN = v
}
}
if topN > maxTrafficTopN {
c.JSON(http.StatusBadRequest, gin.H{"error": "top_n cannot exceed 20"})
return
}
// Build query
q := h.logBaseQuery().
Where("created_at >= ?", sinceTime).
Where("created_at <= ?", untilTime)
// Select with time truncation based on granularity
var truncFunc string
if granularity == "minute" {
truncFunc = "DATE_TRUNC('minute', created_at)"
} else {
truncFunc = "DATE_TRUNC('hour', created_at)"
}
type bucketModelStats struct {
Bucket time.Time
ModelName string
Cnt int64
TokensIn int64
TokensOut int64
}
var rows []bucketModelStats
if err := q.Select(truncFunc+" as bucket, model_name, COUNT(*) as cnt, COALESCE(SUM(tokens_in),0) as tokens_in, COALESCE(SUM(tokens_out),0) as tokens_out").
Group("bucket, model_name").
Order("bucket ASC, cnt DESC").
Scan(&rows).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to aggregate traffic data", "details": err.Error()})
return
}
// Calculate global model counts for top_n selection
modelCounts := make(map[string]int64)
for _, r := range rows {
modelCounts[r.ModelName] += r.Cnt
}
// Get top N models
type modelCount struct {
name string
count int64
}
var modelList []modelCount
for name, cnt := range modelCounts {
modelList = append(modelList, modelCount{name, cnt})
}
// Sort by count descending
for i := 0; i < len(modelList)-1; i++ {
for j := i + 1; j < len(modelList); j++ {
if modelList[j].count > modelList[i].count {
modelList[i], modelList[j] = modelList[j], modelList[i]
}
}
}
topModels := make(map[string]bool)
var modelNames []string
for i := 0; i < len(modelList) && i < topN; i++ {
topModels[modelList[i].name] = true
modelNames = append(modelNames, modelList[i].name)
}
hasOther := len(modelList) > topN
if hasOther {
modelNames = append(modelNames, "other")
}
// Build buckets with breakdown
bucketMap := make(map[int64]*TrafficBucket)
var bucketOrder []int64
for _, r := range rows {
ts := r.Bucket.Unix()
bucket, exists := bucketMap[ts]
if !exists {
bucket = &TrafficBucket{
Time: r.Bucket.UTC().Format(time.RFC3339),
Timestamp: ts,
Breakdown: make(map[string]TrafficMetrics),
}
bucketMap[ts] = bucket
bucketOrder = append(bucketOrder, ts)
}
modelKey := r.ModelName
if !topModels[modelKey] {
modelKey = "other"
}
existing := bucket.Breakdown[modelKey]
bucket.Breakdown[modelKey] = TrafficMetrics{
Count: existing.Count + r.Cnt,
TokensIn: existing.TokensIn + r.TokensIn,
TokensOut: existing.TokensOut + r.TokensOut,
}
bucket.Total.Count += r.Cnt
bucket.Total.TokensIn += r.TokensIn
bucket.Total.TokensOut += r.TokensOut
}
// Build response buckets in order
buckets := make([]TrafficBucket, 0, len(bucketOrder))
for _, ts := range bucketOrder {
buckets = append(buckets, *bucketMap[ts])
}
c.JSON(http.StatusOK, TrafficChartResponse{
Granularity: granularity,
Since: sinceTime.Unix(),
Until: untilTime.Unix(),
Models: modelNames,
Buckets: buckets,
})
}
// ListSelfLogs godoc // ListSelfLogs godoc
// @Summary List logs (master) // @Summary List logs (master)
// @Description List request logs for the authenticated master // @Description List request logs for the authenticated master