mirror of
https://github.com/EZ-Api/ez-api.git
synced 2026-01-13 17:47:51 +00:00
refactor(api): update traffic chart response structure
Change the traffic chart API response from bucket-based to series-based to better support frontend visualization libraries. The new format provides a shared X-axis and aligned data arrays for each model series. - Replace `buckets` with `x` and `series` in response - Implement data alignment and zero-filling for time slots - Update Swagger documentation including pending definitions BREAKING CHANGE: The `GET /admin/logs/stats/traffic-chart` response schema has changed. `buckets` and `models` fields are removed.
This commit is contained in:
@@ -609,34 +609,35 @@ func (h *Handler) logStatsByMinute(c *gin.Context, q *gorm.DB, sinceTime, untilT
|
||||
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"`
|
||||
// TrafficSeries contains the metrics for a model aligned to the shared time axis.
|
||||
type TrafficSeries struct {
|
||||
Name string `json:"name"`
|
||||
Data []int64 `json:"data"`
|
||||
TokensIn []int64 `json:"tokens_in"`
|
||||
TokensOut []int64 `json:"tokens_out"`
|
||||
}
|
||||
|
||||
// ModelMetricsMap is a map from model name to TrafficMetrics.
|
||||
// Keys are model names (e.g. "gpt-4", "claude-3-opus") or "other" for aggregated remaining models.
|
||||
// Example: {"gpt-4": {"count": 10, "tokens_in": 1000, "tokens_out": 500}, "other": {"count": 3, "tokens_in": 200, "tokens_out": 100}}
|
||||
type ModelMetricsMap map[string]TrafficMetrics
|
||||
// TrafficTotals contains aggregated totals aligned to the shared time axis.
|
||||
type TrafficTotals struct {
|
||||
Data []int64 `json:"data"`
|
||||
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 is a map from model name (e.g. "gpt-4", "claude-3-opus", "other") to its metrics
|
||||
Breakdown ModelMetricsMap `json:"breakdown"`
|
||||
Total TrafficMetrics `json:"total"`
|
||||
// TrafficChartAxis defines the shared time axis for chart data.
|
||||
type TrafficChartAxis struct {
|
||||
Labels []string `json:"labels"`
|
||||
Timestamps []int64 `json:"timestamps"`
|
||||
Totals TrafficTotals `json:"totals"`
|
||||
}
|
||||
|
||||
// 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"`
|
||||
Granularity string `json:"granularity"`
|
||||
Since int64 `json:"since"`
|
||||
Until int64 `json:"until"`
|
||||
X TrafficChartAxis `json:"x"`
|
||||
Series []TrafficSeries `json:"series"`
|
||||
}
|
||||
|
||||
const (
|
||||
@@ -644,9 +645,121 @@ const (
|
||||
maxTrafficTopN = 20
|
||||
)
|
||||
|
||||
type trafficBucketRow struct {
|
||||
Bucket time.Time
|
||||
ModelName string
|
||||
Cnt int64
|
||||
TokensIn int64
|
||||
TokensOut int64
|
||||
}
|
||||
|
||||
func buildTrafficChartSeriesResponse(rows []trafficBucketRow, topN int, granularity string, sinceTime, untilTime time.Time) TrafficChartResponse {
|
||||
bucketLabels := make(map[int64]string)
|
||||
bucketOrder := make([]int64, 0)
|
||||
for _, r := range rows {
|
||||
ts := r.Bucket.Unix()
|
||||
if _, exists := bucketLabels[ts]; !exists {
|
||||
bucketLabels[ts] = r.Bucket.UTC().Format(time.RFC3339)
|
||||
bucketOrder = append(bucketOrder, ts)
|
||||
}
|
||||
}
|
||||
|
||||
modelCounts := make(map[string]int64)
|
||||
for _, r := range rows {
|
||||
modelCounts[r.ModelName] += r.Cnt
|
||||
}
|
||||
|
||||
type modelCount struct {
|
||||
name string
|
||||
count int64
|
||||
}
|
||||
modelList := make([]modelCount, 0, len(modelCounts))
|
||||
for name, cnt := range modelCounts {
|
||||
modelList = append(modelList, modelCount{name, cnt})
|
||||
}
|
||||
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, topN)
|
||||
seriesNames := make([]string, 0, topN+1)
|
||||
for i := 0; i < len(modelList) && i < topN; i++ {
|
||||
topModels[modelList[i].name] = true
|
||||
seriesNames = append(seriesNames, modelList[i].name)
|
||||
}
|
||||
if len(modelList) > topN {
|
||||
seriesNames = append(seriesNames, "other")
|
||||
}
|
||||
|
||||
bucketIndex := make(map[int64]int, len(bucketOrder))
|
||||
labels := make([]string, len(bucketOrder))
|
||||
timestamps := make([]int64, len(bucketOrder))
|
||||
for i, ts := range bucketOrder {
|
||||
bucketIndex[ts] = i
|
||||
labels[i] = bucketLabels[ts]
|
||||
timestamps[i] = ts
|
||||
}
|
||||
|
||||
series := make([]TrafficSeries, len(seriesNames))
|
||||
seriesIndex := make(map[string]int, len(seriesNames))
|
||||
for i, name := range seriesNames {
|
||||
series[i] = TrafficSeries{
|
||||
Name: name,
|
||||
Data: make([]int64, len(bucketOrder)),
|
||||
TokensIn: make([]int64, len(bucketOrder)),
|
||||
TokensOut: make([]int64, len(bucketOrder)),
|
||||
}
|
||||
seriesIndex[name] = i
|
||||
}
|
||||
|
||||
totals := TrafficTotals{
|
||||
Data: make([]int64, len(bucketOrder)),
|
||||
TokensIn: make([]int64, len(bucketOrder)),
|
||||
TokensOut: make([]int64, len(bucketOrder)),
|
||||
}
|
||||
|
||||
for _, r := range rows {
|
||||
ts := r.Bucket.Unix()
|
||||
idx, ok := bucketIndex[ts]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
modelKey := r.ModelName
|
||||
if !topModels[modelKey] {
|
||||
modelKey = "other"
|
||||
}
|
||||
if seriesIdx, exists := seriesIndex[modelKey]; exists {
|
||||
series[seriesIdx].Data[idx] += r.Cnt
|
||||
series[seriesIdx].TokensIn[idx] += r.TokensIn
|
||||
series[seriesIdx].TokensOut[idx] += r.TokensOut
|
||||
}
|
||||
|
||||
totals.Data[idx] += r.Cnt
|
||||
totals.TokensIn[idx] += r.TokensIn
|
||||
totals.TokensOut[idx] += r.TokensOut
|
||||
}
|
||||
|
||||
return TrafficChartResponse{
|
||||
Granularity: granularity,
|
||||
Since: sinceTime.Unix(),
|
||||
Until: untilTime.Unix(),
|
||||
X: TrafficChartAxis{
|
||||
Labels: labels,
|
||||
Timestamps: timestamps,
|
||||
Totals: totals,
|
||||
},
|
||||
Series: series,
|
||||
}
|
||||
}
|
||||
|
||||
// GetTrafficChart godoc
|
||||
// @Summary Traffic chart data (admin)
|
||||
// @Description Get time × model aggregated data for stacked traffic charts. Returns time buckets with per-model breakdown. The 'breakdown' field in each bucket is a map where keys are model names (e.g. "gpt-4", "claude-3-opus") and values are TrafficMetrics objects. Models outside top_n are aggregated under the key "other". Example: {"gpt-4": {"count": 10, "tokens_in": 1000, "tokens_out": 500}, "other": {"count": 3, "tokens_in": 200, "tokens_out": 100}}
|
||||
// @Description Get time × model aggregated data for stacked traffic charts. Returns a shared time axis under `x` and per-model series arrays aligned to that axis. Models outside top_n are aggregated under the series name "other".
|
||||
// @Tags admin
|
||||
// @Produce json
|
||||
// @Security AdminAuth
|
||||
@@ -731,14 +844,7 @@ func (h *Handler) GetTrafficChart(c *gin.Context) {
|
||||
truncFunc = "DATE_TRUNC('hour', created_at)"
|
||||
}
|
||||
|
||||
type bucketModelStats struct {
|
||||
Bucket time.Time
|
||||
ModelName string
|
||||
Cnt int64
|
||||
TokensIn int64
|
||||
TokensOut int64
|
||||
}
|
||||
var rows []bucketModelStats
|
||||
var rows []trafficBucketRow
|
||||
|
||||
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").
|
||||
@@ -748,88 +854,7 @@ func (h *Handler) GetTrafficChart(c *gin.Context) {
|
||||
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,
|
||||
})
|
||||
c.JSON(http.StatusOK, buildTrafficChartSeriesResponse(rows, topN, granularity, sinceTime, untilTime))
|
||||
}
|
||||
|
||||
// ListSelfLogs godoc
|
||||
|
||||
Reference in New Issue
Block a user