Files

983 lines
35 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package monitor
import (
"context"
"fmt"
"github.com/qianfree/team-api/api/admin/v1"
"github.com/qianfree/team-api/internal/dao"
"github.com/qianfree/team-api/internal/logic/billing"
"github.com/qianfree/team-api/internal/middleware"
"math"
"sort"
"time"
"github.com/gogf/gf/v2/frame/g"
"github.com/qianfree/team-api/internal/logic/common"
)
// ⚠️ 平台域查询约定(P2-13)
//
// 本文件中的 GetAPIMetrics / GetTrafficCurve / GetLatencyHistogram / GetErrorRate /
// GetP99Latency / GetQPS 等聚合查询基于 bil_usage_logs 做【跨租户平台级】统计,
// 查询不带 tenant_id 过滤——这是管理后台(平台运营视角)所需的。
//
// 风险:这些函数均为 exported,若被错误地复用到租户控制台接口,会泄露其它租户的用量数据。
// 防护:除 admin 路由鉴权外,HTTP 入口(sMonitor 仪表盘相关方法)额外调用
// requireAdminScope 做调用方身份校验,即便有人误把接口接到租户路由也会被拒绝。
// 内部 cron 上下文(gctx.New,无 userType)放行。
// requireAdminScope 断言调用方为平台运营域(admin 或内部上下文)。
// 用于保护跨租户聚合的监控查询,防止被租户域误用导致数据泄露(P2-13)。
func requireAdminScope(ctx context.Context) error {
switch middleware.GetUserType(ctx) {
case "admin", "":
// admin:平台运营后台;"":内部 cron/任务上下文(gctx.New 未设 userType),均放行
return nil
case "tenant":
return fmt.Errorf("监控聚合查询为平台域只读接口,禁止租户域访问")
default:
return fmt.Errorf("未知调用方域,拒绝访问平台域监控查询: user_type=%s", middleware.GetUserType(ctx))
}
}
// ===================== 流量流向桑基图(bil_usage_daily) =====================
// trafficFlowMaxDays 桑基图最大查询跨度(天)
const trafficFlowMaxDays = 90
// trafficFlowTopN 租户/模型维度保留的节点数,其余归入「其他」
const trafficFlowTopN = 12
// trafficFlowRow 桑基图原始聚合行:bil_usage_daily 按租户/模型/渠道/状态聚合
type trafficFlowRow struct {
TenantID int64 `json:"tenant_id"`
TenantName string `json:"tenant_name"`
ModelName string `json:"model_name"`
ChannelID int64 `json:"channel_id"`
ChannelName string `json:"channel_name"`
Status string `json:"status"`
Requests int64 `json:"requests"`
Tokens int64 `json:"tokens"`
Cost float64 `json:"cost"`
}
// normalizeMonitorDateRange 规范化监控类接口的日期范围,返回 YYYY-MM-DD 的 [start, end](含)。
// 缺省近 30 天;end 不超过今天;跨度上限 trafficFlowMaxDays。流量桑基图与模型性能共用。
func normalizeMonitorDateRange(startDate, endDate string) (string, string) {
return normalizeMonitorDateRangeAt(startDate, endDate, time.Now())
}
// normalizeMonitorDateRangeAt is separated for deterministic tests. Date-only
// filters must be compared as calendar dates in the server's location, rather
// than against the current instant. Otherwise, on a non-UTC server before its
// UTC offset, today's parsed midnight can incorrectly fall after time.Now()
// and the range silently falls back to the default 30 days.
func normalizeMonitorDateRangeAt(startDate, endDate string, now time.Time) (string, string) {
const layout = "2006-01-02"
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
end, err := time.ParseInLocation(layout, endDate, now.Location())
if err != nil || end.After(today) {
end = today
}
start, err := time.ParseInLocation(layout, startDate, now.Location())
if err != nil || start.After(end) {
start = end.AddDate(0, 0, -29)
}
// 跨度上限
if end.Sub(start).Hours()/24 > float64(trafficFlowMaxDays-1) {
start = end.AddDate(0, 0, -(trafficFlowMaxDays - 1))
}
return start.Format(layout), end.Format(layout)
}
// GetTrafficFlow 从 bil_usage_daily 聚合出流量流向桑基图数据。
// 维度链:租户 → 模型 → 渠道 → 状态;指标 metric: cost|tokens|requests。
// 租户与模型维度做 Top-N + 「其他」归桶,避免高基数导致图形过密。
func GetTrafficFlow(ctx context.Context, startDate, endDate, metric string) (any, error) {
const query = `
SELECT
d.tenant_id AS tenant_id,
COALESCE(t.name, '(已删除 ' || d.tenant_id || ')') AS tenant_name,
d.model_name AS model_name,
d.channel_id AS channel_id,
COALESCE(c.name, CASE WHEN d.channel_id = 0 THEN '(无渠道)'
ELSE '(已删除 ' || d.channel_id || ')' END) AS channel_name,
d.status AS status,
COALESCE(SUM(d.request_count), 0) AS requests,
COALESCE(SUM(d.input_tokens + d.output_tokens), 0) AS tokens,
COALESCE(SUM(d.total_cost), 0) AS cost
FROM bil_usage_daily d
LEFT JOIN tnt_tenants t ON t.id = d.tenant_id
LEFT JOIN chn_channels c ON c.id = d.channel_id
WHERE d.stat_date >= $1 AND d.stat_date <= $2
GROUP BY d.tenant_id, t.name, d.model_name, d.channel_id, c.name, d.status
`
var rows []trafficFlowRow
if err := g.DB().Ctx(ctx).Raw(query, startDate, endDate).Scan(&rows); err != nil {
return nil, err
}
return buildTrafficFlowSankey(rows, metric), nil
}
// ===================== 模型性能指标(bil_usage_daily) =====================
// modelPerfRow 模型性能聚合行(bil_usage_daily 按 model_name 分组)
type modelPerfRow struct {
ModelName string `json:"model_name"`
RequestCount int64 `json:"request_count"`
SuccessCount int64 `json:"success_count"`
InputTokens int64 `json:"input_tokens"`
OutputTokens int64 `json:"output_tokens"`
TotalCost float64 `json:"total_cost"`
SumLatencyMs int64 `json:"sum_latency_ms"`
SumFirstTokenMs int64 `json:"sum_first_token_ms"`
// 缓存聚合列(000016 迁移新增,口径与 bil_usage_logs 明细一致)
CacheCreationTokens int64 `json:"cache_creation_tokens"`
CacheReadTokens int64 `json:"cache_read_tokens"`
CacheHitRequestCount int64 `json:"cache_hit_request_count"`
}
// gradeSuccessRate 按成功率返回分级。阈值:≥99 excellent、≥95 good、≥90 warning、<90 critical。
func gradeSuccessRate(rate float64) string {
switch {
case rate >= 99:
return "excellent"
case rate >= 95:
return "good"
case rate >= 90:
return "warning"
default:
return "critical"
}
}
// perfModelAgg 模型性能合并聚合值(历史日表 + 当日 Redis 热桶)。
type perfModelAgg struct {
requestCount int64
successCount int64
inputTokens int64
outputTokens int64
sumLatencyMs int64
sumFirstTokenMs int64
totalCost float64
// 缓存累计(历史日表 + 当日热桶同口径累加)
cacheCreationTokens int64
cacheReadTokens int64
cacheHitRequests int64
}
// GetModelPerformance 从 bil_usage_daily 按模型聚合历史性能指标,并合并当日实时数据,
// 支持可选的渠道(channelID,0=全部)与模型(modelName,空=全部)过滤:选定渠道后可查看
// 该渠道下各模型的性能。历史段经 bil_usage_daily(唯一键含 channel_id)过滤;
// 当天段在渠道过滤下改走 bil_usage_logs 明细实时聚合(热桶无渠道维度),两段口径一致。
// 指标口径与 bil_usage_daily 一致:延迟按 SUM 求和、视图以 SUM/COUNT 求均值(分位数不可跨桶加,p95/p99 留后续)。
func GetModelPerformance(ctx context.Context, startDate, endDate string, channelID int64, modelName string) ([]v1.ModelPerformanceSummary, error) {
today := time.Now().Format("2006-01-02")
// 历史段:stat_date < today(当天由实时数据提供,避免与每日 01:00 的日聚合重复/滞后)。
// 渠道/模型过滤用「参数=哨兵值 OR 等值匹配」写法保持 SQL 常量字符串,未筛选时等价于无条件;
// $4/$5 显式转型规避 PostgreSQL 对 OR 两侧参数的类型推断歧义。
const query = `
SELECT
model_name,
SUM(request_count) AS request_count,
SUM(CASE WHEN status = 'success' THEN request_count ELSE 0 END) AS success_count,
COALESCE(SUM(input_tokens), 0) AS input_tokens,
COALESCE(SUM(output_tokens), 0) AS output_tokens,
COALESCE(SUM(total_cost), 0) AS total_cost,
COALESCE(SUM(sum_latency_ms), 0) AS sum_latency_ms,
COALESCE(SUM(sum_first_token_ms), 0) AS sum_first_token_ms,
COALESCE(SUM(cache_creation_tokens), 0) AS cache_creation_tokens,
COALESCE(SUM(cache_read_tokens), 0) AS cache_read_tokens,
COALESCE(SUM(cache_hit_request_count), 0) AS cache_hit_request_count
FROM bil_usage_daily
WHERE stat_date >= $1 AND stat_date <= $2 AND stat_date < $3 AND model_name <> 'unknown'
AND ($4::bigint = 0 OR channel_id = $4)
AND ($5::text = '' OR model_name = $5)
GROUP BY model_name
`
var rows []modelPerfRow
if err := g.DB().Ctx(ctx).Raw(query, startDate, endDate, today, channelID, modelName).Scan(&rows); err != nil {
return nil, err
}
// 当天段(best-effort:仅当查询窗口包含今天时参与;数据源失败则忽略当日数据,退化为历史日表):
// - 选定渠道:Redis 热桶无渠道维度,改从 bil_usage_logs 当天明细实时聚合(输出同构,合并逻辑复用)
// - 未选渠道:保持热桶路径与现状一致;仅选模型时热桶键即模型名,按键过滤
todayCounts := map[string]common.ModelPerfCounter{}
if endDate == today {
if channelID > 0 {
tc, err := getModelPerfTodayFromLogs(ctx, today, channelID, modelName)
if err != nil {
g.Log().Warningf(ctx, "model performance today logs aggregate failed, fallback to daily only: %v", err)
} else {
todayCounts = tc
}
} else {
tc, err := common.GetModelPerfMetricsToday(ctx, today)
if err != nil {
g.Log().Warningf(ctx, "model performance today redis read failed, fallback to daily only: %v", err)
} else {
todayCounts = filterTodayCounts(tc, modelName)
}
}
}
return buildModelPerformanceList(rows, todayCounts), nil
}
// GetModelChannels 单个模型各渠道性能对比(行展开用):按渠道分组聚合指定模型的性能数据。
// 返回该模型在各个渠道的请求数/成功率/延迟/缓存命中,供前端点击展开行时渲染渠道对比小表。
func GetModelChannels(ctx context.Context, startDate, endDate, modelName string) ([]v1.ModelChannelPerformance, error) {
today := time.Now().Format("2006-01-02")
// 历史段:从 bil_usage_daily 按 channel_id 分组聚合指定模型
const query = `
SELECT
channel_id,
SUM(request_count) AS request_count,
SUM(CASE WHEN status = 'success' THEN request_count ELSE 0 END) AS success_count,
COALESCE(SUM(sum_latency_ms), 0) AS sum_latency_ms,
COALESCE(SUM(sum_first_token_ms), 0) AS sum_first_token_ms,
COALESCE(SUM(output_tokens), 0) AS output_tokens,
COALESCE(SUM(cache_read_tokens), 0) AS cache_read_tokens,
COALESCE(SUM(cache_creation_tokens), 0) AS cache_creation_tokens,
COALESCE(SUM(cache_hit_request_count), 0) AS cache_hit_request_count
FROM bil_usage_daily
WHERE stat_date >= $1 AND stat_date <= $2 AND stat_date < $3 AND model_name = $4
GROUP BY channel_id
`
type channelPerfRow struct {
ChannelID int64 `json:"channel_id"`
RequestCount int64 `json:"request_count"`
SuccessCount int64 `json:"success_count"`
SumLatencyMs int64 `json:"sum_latency_ms"`
SumFirstTokenMs int64 `json:"sum_first_token_ms"`
OutputTokens int64 `json:"output_tokens"`
CacheReadTokens int64 `json:"cache_read_tokens"`
CacheCreationTokens int64 `json:"cache_creation_tokens"`
CacheHitRequestCount int64 `json:"cache_hit_request_count"`
}
var rows []channelPerfRow
if err := g.DB().Ctx(ctx).Raw(query, startDate, endDate, today, modelName).Scan(&rows); err != nil {
return nil, err
}
// 当天段:仅当查询窗口包含今天时,从 bil_usage_logs 按渠道实时聚合
todayCounts := map[int64]*channelPerfRow{}
if endDate == today {
dayStart, err := time.ParseInLocation("2006-01-02", today, time.Local)
if err != nil {
return nil, err
}
start := today + " 00:00:00"
end := dayStart.AddDate(0, 0, 1).Format("2006-01-02") + " 00:00:00"
const todayQuery = `
SELECT
channel_id,
COUNT(*) AS request_count,
COUNT(*) FILTER (WHERE status = 'success') AS success_count,
COALESCE(SUM(latency_ms), 0) AS sum_latency_ms,
COALESCE(SUM(first_token_ms), 0) AS sum_first_token_ms,
COALESCE(SUM(output_tokens), 0) AS output_tokens,
COALESCE(SUM(cache_read_tokens), 0) AS cache_read_tokens,
COALESCE(SUM(cache_creation_tokens), 0) AS cache_creation_tokens,
COUNT(*) FILTER (WHERE cache_read_tokens > 0) AS cache_hit_request_count
FROM bil_usage_logs
WHERE created_at >= $1 AND created_at < $2 AND model_name = $3
GROUP BY channel_id
`
var todayRows []channelPerfRow
if err := g.DB().Ctx(ctx).Raw(todayQuery, start, end, modelName).Scan(&todayRows); err != nil {
g.Log().Warningf(ctx, "model channels today aggregate failed, fallback to daily only: %v", err)
} else {
for _, r := range todayRows {
todayCounts[r.ChannelID] = &r
}
}
}
// 合并历史 + 当天
agg := make(map[int64]*channelPerfRow)
for _, r := range rows {
agg[r.ChannelID] = &r
}
for cid, tc := range todayCounts {
if base, ok := agg[cid]; ok {
base.RequestCount += tc.RequestCount
base.SuccessCount += tc.SuccessCount
base.SumLatencyMs += tc.SumLatencyMs
base.SumFirstTokenMs += tc.SumFirstTokenMs
base.OutputTokens += tc.OutputTokens
base.CacheReadTokens += tc.CacheReadTokens
base.CacheCreationTokens += tc.CacheCreationTokens
base.CacheHitRequestCount += tc.CacheHitRequestCount
} else {
agg[cid] = tc
}
}
// 查询渠道名(批量读取,避免 N+1)
channelIDs := make([]int64, 0, len(agg))
for cid := range agg {
channelIDs = append(channelIDs, cid)
}
channelNames := make(map[int64]string)
if len(channelIDs) > 0 {
var channels []struct {
ID int64 `json:"id"`
Name string `json:"name"`
}
if err := g.DB().Ctx(ctx).Model("chn_channels").
Fields("id", "name").
WhereIn("id", channelIDs).
Scan(&channels); err != nil {
g.Log().Warningf(ctx, "query channel names failed: %v", err)
} else {
for _, ch := range channels {
channelNames[ch.ID] = ch.Name
}
}
}
// 构建输出列表(重算派生指标:成功率/均延迟/均首Token/TPS/缓存命中率)
list := make([]v1.ModelChannelPerformance, 0, len(agg))
for cid, r := range agg {
successRate := float64(0)
if r.RequestCount > 0 {
successRate = float64(r.SuccessCount) / float64(r.RequestCount) * 100
}
avgLatency := float64(0)
if r.RequestCount > 0 {
avgLatency = float64(r.SumLatencyMs) / float64(r.RequestCount)
}
avgFirstToken := float64(0)
if r.RequestCount > 0 {
avgFirstToken = float64(r.SumFirstTokenMs) / float64(r.RequestCount)
}
tps := float64(0)
if r.SumLatencyMs > 0 {
// TPS 口径与模型层(buildModelPerformanceList)一致:Σ 输出 token / (Σ 延迟ms / 1000)。
// 不能用输入+输出总量:输入 token 为「含缓存总输入」口径,命中缓存的请求会把 TPS 虚高数倍
tps = float64(r.OutputTokens) / (float64(r.SumLatencyMs) / 1000.0)
}
cacheHitRate := float64(0)
totalCacheTokens := r.CacheReadTokens + r.CacheCreationTokens
if totalCacheTokens > 0 {
cacheHitRate = float64(r.CacheReadTokens) / float64(totalCacheTokens) * 100
}
cacheHitRequestRate := float64(0)
if r.RequestCount > 0 {
cacheHitRequestRate = float64(r.CacheHitRequestCount) / float64(r.RequestCount) * 100
}
channelName := channelNames[cid]
if channelName == "" {
channelName = fmt.Sprintf("#%d", cid)
}
list = append(list, v1.ModelChannelPerformance{
ChannelID: cid,
ChannelName: channelName,
RequestCount: int(r.RequestCount),
SuccessCount: int(r.SuccessCount),
SuccessRate: successRate,
AvgLatencyMs: avgLatency,
AvgFirstTokenMs: avgFirstToken,
TPS: tps,
CacheCreationTokens: r.CacheCreationTokens,
CacheReadTokens: r.CacheReadTokens,
CacheHitRequestCount: int(r.CacheHitRequestCount),
CacheHitRequestRate: cacheHitRequestRate,
CacheHitRate: cacheHitRate,
})
}
// 按请求数降序排列(请求量大的渠道排前面,最能反映主力渠道表现)
sort.Slice(list, func(i, j int) bool {
return list[i].RequestCount > list[j].RequestCount
})
return list, nil
}
// filterTodayCounts 热桶结果按模型过滤(热桶键即模型名;modelName 空 = 原样返回)。
// 未命中返回 nil,交给合并层按空处理。
func filterTodayCounts(tc map[string]common.ModelPerfCounter, modelName string) map[string]common.ModelPerfCounter {
if modelName == "" {
return tc
}
if c, ok := tc[modelName]; ok {
return map[string]common.ModelPerfCounter{modelName: c}
}
return nil
}
// modelPerfTodayRow 当天明细聚合行(bil_usage_logs 按模型分组,仅渠道筛选路径使用)。
// 列名对齐 common.ModelPerfCounter 语义;口径与 usage_aggregation 写入 bil_usage_daily 一致:
// ok=成功请求、ttft=Σ首Token(无首Token请求计 0)、ttft_n=有首Token样本数、chit=命中缓存的请求数。
type modelPerfTodayRow struct {
ModelName string `json:"model_name"`
Req int64 `json:"req"`
Ok int64 `json:"ok"`
Lat int64 `json:"lat"`
Ttft int64 `json:"ttft"`
TtftN int64 `json:"ttft_n"`
Tin int64 `json:"tin"`
Tout int64 `json:"tout"`
TotalCost float64 `json:"total_cost"`
CacheCreation int64 `json:"cache_creation"`
CacheRead int64 `json:"cache_read"`
Chit int64 `json:"chit"`
}
// getModelPerfTodayFromLogs 渠道筛选下的当天数据源:从 bil_usage_logs 明细按模型实时聚合。
// Redis 热桶(perf_model:{model}:{hour})无渠道维度,选定渠道后当天必须回明细表;
// 时间区间沿用日聚合的半开区间口径 [today 00:00, tomorrow 00:00),命中 created_at 的 BRIN 索引
// (tomorrow 从 today 参数推导而非另取 now,避免跨午夜瞬间的边界漂移)。
// 输出与 common.GetModelPerfMetricsToday 同构,合并逻辑复用不分叉。
func getModelPerfTodayFromLogs(ctx context.Context, today string, channelID int64, modelName string) (map[string]common.ModelPerfCounter, error) {
dayStart, err := time.ParseInLocation("2006-01-02", today, time.Local)
if err != nil {
return nil, err
}
start := today + " 00:00:00"
end := dayStart.AddDate(0, 0, 1).Format("2006-01-02") + " 00:00:00"
const query = `
SELECT
model_name AS model_name,
COUNT(*) AS req,
COUNT(*) FILTER (WHERE status = 'success') AS ok,
COALESCE(SUM(latency_ms), 0) AS lat,
COALESCE(SUM(first_token_ms), 0) AS ttft,
COUNT(*) FILTER (WHERE first_token_ms > 0) AS ttft_n,
COALESCE(SUM(input_tokens), 0) AS tin,
COALESCE(SUM(output_tokens), 0) AS tout,
COALESCE(SUM(total_cost), 0) AS total_cost,
COALESCE(SUM(cache_creation_tokens), 0) AS cache_creation,
COALESCE(SUM(cache_read_tokens), 0) AS cache_read,
COUNT(*) FILTER (WHERE cache_read_tokens > 0) AS chit
FROM bil_usage_logs
WHERE created_at >= $1 AND created_at < $2 AND channel_id = $3
AND ($4::text = '' OR model_name = $4)
GROUP BY model_name
`
var rows []modelPerfTodayRow
if err := g.DB().Ctx(ctx).Raw(query, start, end, channelID, modelName).Scan(&rows); err != nil {
return nil, err
}
return rowsToTodayCounters(rows), nil
}
// rowsToTodayCounters 当天明细聚合行 → 热桶同构计数。
// total_cost 以 float 聚合后转整数 micro-USD(common.microFromUSD 未导出,此处同式内联),
// 合并侧统一经 billing.FromMicro 还原 USD,保证两条当天数据路径口径一致。
func rowsToTodayCounters(rows []modelPerfTodayRow) map[string]common.ModelPerfCounter {
out := make(map[string]common.ModelPerfCounter, len(rows))
for _, r := range rows {
out[r.ModelName] = common.ModelPerfCounter{
Req: r.Req,
Ok: r.Ok,
Lat: r.Lat,
Ttft: r.Ttft,
TtftN: r.TtftN,
Tin: r.Tin,
Tout: r.Tout,
CostMicro: int64(math.Round(r.TotalCost * 1e6)),
CacheCreation: r.CacheCreation,
CacheRead: r.CacheRead,
CacheHitReq: r.Chit,
}
}
return out
}
// buildModelPerformanceList 将历史日表聚合行与当日 Redis 热桶合并为统一的模型性能列表,
// 统一重算派生指标(成功率/均延迟/均首Token/TPS/分级/缓存命中率)并按模型名称升序输出。
func buildModelPerformanceList(rows []modelPerfRow, todayCounts map[string]common.ModelPerfCounter) []v1.ModelPerformanceSummary {
agg := make(map[string]*perfModelAgg)
ensure := func(model string) *perfModelAgg {
a, ok := agg[model]
if !ok {
a = &perfModelAgg{}
agg[model] = a
}
return a
}
for _, r := range rows {
if r.ModelName == "" || r.ModelName == "unknown" {
continue
}
a := ensure(r.ModelName)
a.requestCount += r.RequestCount
a.successCount += r.SuccessCount
a.inputTokens += r.InputTokens
a.outputTokens += r.OutputTokens
a.sumLatencyMs += r.SumLatencyMs
a.sumFirstTokenMs += r.SumFirstTokenMs
a.totalCost += r.TotalCost
a.cacheCreationTokens += r.CacheCreationTokens
a.cacheReadTokens += r.CacheReadTokens
a.cacheHitRequests += r.CacheHitRequestCount
}
for model, c := range todayCounts {
if c.Req <= 0 || model == "" || model == "unknown" {
continue
}
a := ensure(model)
a.requestCount += c.Req
a.successCount += c.Ok
a.inputTokens += c.Tin
a.outputTokens += c.Tout
a.sumLatencyMs += c.Lat
a.sumFirstTokenMs += c.Ttft
a.totalCost += billing.FromMicro(c.CostMicro).InexactFloat64()
a.cacheCreationTokens += c.CacheCreation
a.cacheReadTokens += c.CacheRead
a.cacheHitRequests += c.CacheHitReq
}
list := make([]v1.ModelPerformanceSummary, 0, len(agg))
for model, a := range agg {
var successRate, avgLatency, avgTTFT, tps float64
if a.requestCount > 0 {
successRate = float64(a.successCount) * 100 / float64(a.requestCount)
avgLatency = float64(a.sumLatencyMs) / float64(a.requestCount)
avgTTFT = float64(a.sumFirstTokenMs) / float64(a.requestCount)
}
if a.sumLatencyMs > 0 {
tps = float64(a.outputTokens) / (float64(a.sumLatencyMs) / 1000)
}
// 缓存命中率(Token 级)= 命中读取 / 总输入。input_tokens 已统一为「含缓存总输入」口径
//(Claude 渠道入库时补加缓存读写,OpenAI/Gemini 原生即含),分母直接取 input_tokens,
// 跨渠道口径一致。请求级命中率分母为 request_count,不受该口径影响。
var cacheHitRate, cacheHitReqRate float64
if a.inputTokens > 0 {
cacheHitRate = float64(a.cacheReadTokens) * 100 / float64(a.inputTokens)
}
if a.requestCount > 0 {
cacheHitReqRate = float64(a.cacheHitRequests) * 100 / float64(a.requestCount)
}
list = append(list, v1.ModelPerformanceSummary{
ModelName: model,
RequestCount: int(a.requestCount),
SuccessCount: int(a.successCount),
SuccessRate: math.Round(successRate*100) / 100,
Grade: gradeSuccessRate(successRate),
InputTokens: a.inputTokens,
OutputTokens: a.outputTokens,
TotalTokens: a.inputTokens + a.outputTokens,
TotalCost: a.totalCost,
AvgLatencyMs: math.Round(avgLatency*100) / 100,
AvgFirstTokenMs: math.Round(avgTTFT*100) / 100,
TPS: math.Round(tps*100) / 100,
CacheCreationTokens: a.cacheCreationTokens,
CacheReadTokens: a.cacheReadTokens,
CacheHitRate: math.Round(cacheHitRate*100) / 100,
CacheHitRequestCount: int(a.cacheHitRequests),
CacheHitRequestRate: math.Round(cacheHitReqRate*100) / 100,
})
}
// 默认按模型名称升序排列,便于运营按名称定位具体模型;用户可在前端表格点任意列切换排序。
sort.Slice(list, func(i, j int) bool {
return list[i].ModelName < list[j].ModelName
})
return list
}
// buildTrafficFlowSankey 将聚合行组装为 ECharts Sankey 的 {nodes, links}。
// 节点名加段前缀(租户/ 模型/ 渠道/ 状态/)保证全局唯一。
func buildTrafficFlowSankey(rows []trafficFlowRow, metric string) map[string]any {
weight := func(r trafficFlowRow) float64 {
switch metric {
case "tokens":
return float64(r.Tokens)
case "requests":
return float64(r.Requests)
default:
return r.Cost
}
}
// 计算租户/模型总权重,用于 Top-N 归桶
tenantTotal := map[string]float64{}
modelTotal := map[string]float64{}
for _, r := range rows {
w := weight(r)
tenantTotal[r.TenantName] += w
modelTotal[r.ModelName] += w
}
keepTenant := topNKeys(tenantTotal, trafficFlowTopN)
keepModel := topNKeys(modelTotal, trafficFlowTopN)
tenantDisplay := func(name string) string {
if keepTenant[name] {
return name
}
return "(其他租户)"
}
modelDisplay := func(name string) string {
if keepModel[name] {
return name
}
return "(其他模型)"
}
const (
pTenant = "租户/"
pModel = "模型/"
pChannel = "渠道/"
pStatus = "状态/"
)
type edge struct{ src, dst string }
linkWeight := map[edge]float64{}
nodeDepth := map[string]int{}
addNode := func(name string, depth int) {
if _, ok := nodeDepth[name]; !ok {
nodeDepth[name] = depth
}
}
addLink := func(src, dst string, w float64) {
if w > 0 {
linkWeight[edge{src, dst}] += w
}
}
for _, r := range rows {
w := weight(r)
if w <= 0 {
continue
}
tn := pTenant + tenantDisplay(r.TenantName)
mn := pModel + modelDisplay(r.ModelName)
cn := pChannel + r.ChannelName
sn := pStatus + r.Status
addNode(tn, 0)
addNode(mn, 1)
addNode(cn, 2)
addNode(sn, 3)
addLink(tn, mn, w)
addLink(mn, cn, w)
addLink(cn, sn, w)
}
nodes := make([]map[string]any, 0, len(nodeDepth))
for name, depth := range nodeDepth {
nodes = append(nodes, map[string]any{"name": name, "depth": depth})
}
links := make([]map[string]any, 0, len(linkWeight))
for e, w := range linkWeight {
links = append(links, map[string]any{
"source": e.src,
"target": e.dst,
"value": math.Round(w*1e6) / 1e6,
})
}
return map[string]any{
"metric": metric,
"nodes": nodes,
"links": links,
}
}
// topNKeys 返回按值降序前 n 个 key 的集合
func topNKeys(m map[string]float64, n int) map[string]bool {
type kv struct {
k string
v float64
}
arr := make([]kv, 0, len(m))
for k, v := range m {
arr = append(arr, kv{k, v})
}
sort.Slice(arr, func(i, j int) bool { return arr[i].v > arr[j].v })
out := make(map[string]bool, n)
for i := 0; i < n && i < len(arr); i++ {
out[arr[i].k] = true
}
return out
}
// APIMetricsResult holds aggregated API metrics.
type APIMetricsResult struct {
QPS float64 `json:"qps"`
TPM float64 `json:"tpm"`
Latency LatencyMetrics `json:"latency"`
ErrorRates ErrorRateMetrics `json:"error_rates"`
}
// LatencyMetrics holds latency percentile data.
type LatencyMetrics struct {
P50 float64 `json:"p50"`
P95 float64 `json:"p95"`
P99 float64 `json:"p99"`
Avg float64 `json:"avg"`
}
// ErrorRateMetrics holds error rate data.
type ErrorRateMetrics struct {
Rate4xx float64 `json:"rate_4xx"`
Rate5xx float64 `json:"rate_5xx"`
Total float64 `json:"total"`
}
// GetAPIMetrics aggregates API metrics from bil_usage_logs for the last N minutes.
func GetAPIMetrics(ctx context.Context, minutes int) (*APIMetricsResult, error) {
if minutes <= 0 {
minutes = 5
}
if minutes > 60 {
minutes = 60
}
since := time.Now().Add(-time.Duration(minutes) * time.Minute)
sinceStr := since.Format("2006-01-02 15:04:05")
// QPS + TPM + error rates
type apiStats struct {
Total int `json:"total"`
Errors int `json:"errors"`
TotalToken int64 `json:"total_tokens"`
}
var stats apiStats
err := dao.BilUsageLogs.Ctx(ctx).
Where("created_at >= ?", sinceStr).
Fields("COUNT(*) as total, COALESCE(SUM(CASE WHEN status != 'success' THEN 1 ELSE 0 END), 0) as errors, COALESCE(SUM(COALESCE(input_tokens,0) + COALESCE(output_tokens,0)), 0) as total_tokens").
Scan(&stats)
if err != nil {
return nil, err
}
seconds := float64(minutes * 60)
qps := float64(stats.Total) / seconds
tpm := float64(stats.TotalToken) / float64(minutes)
errorRate := float64(0)
if stats.Total > 0 {
errorRate = float64(stats.Errors) / float64(stats.Total) * 100
}
// Latency percentiles
type latencyRow struct {
P50 float64 `json:"p50"`
P95 float64 `json:"p95"`
P99 float64 `json:"p99"`
Avg float64 `json:"avg"`
}
var lat latencyRow
err = g.DB().Ctx(ctx).Raw(`
SELECT
COALESCE(percentile_cont(0.50) WITHIN GROUP (ORDER BY latency_ms), 0) as p50,
COALESCE(percentile_cont(0.95) WITHIN GROUP (ORDER BY latency_ms), 0) as p95,
COALESCE(percentile_cont(0.99) WITHIN GROUP (ORDER BY latency_ms), 0) as p99,
COALESCE(AVG(latency_ms), 0) as avg
FROM bil_usage_logs
WHERE created_at >= ? AND latency_ms IS NOT NULL
`, sinceStr).Scan(&lat)
if err != nil {
g.Log().Warningf(ctx, "get latency metrics: %v", err)
}
return &APIMetricsResult{
QPS: qps,
TPM: tpm,
Latency: LatencyMetrics{
P50: lat.P50,
P95: lat.P95,
P99: lat.P99,
Avg: lat.Avg,
},
ErrorRates: ErrorRateMetrics{
Total: errorRate,
},
}, nil
}
// GetTrafficCurve returns per-minute traffic data for the last N minutes.
func GetTrafficCurve(ctx context.Context, minutes int) ([]map[string]any, error) {
if minutes <= 0 {
minutes = 30
}
if minutes > 60 {
minutes = 60
}
since := time.Now().Add(-time.Duration(minutes) * time.Minute).Format("2006-01-02 15:04:05")
result, err := g.DB().Ctx(ctx).Raw(`
SELECT
DATE_TRUNC('minute', created_at) as time,
COUNT(*) as requests,
COALESCE(SUM(COALESCE(input_tokens,0) + COALESCE(output_tokens,0)), 0) as tokens,
COALESCE(AVG(latency_ms), 0) as avg_latency
FROM bil_usage_logs
WHERE created_at >= ?
GROUP BY DATE_TRUNC('minute', created_at)
ORDER BY time ASC
`, since).All()
if err != nil {
return nil, err
}
records := result.List()
return records, nil
}
// GetLatencyHistogram returns P50/P95/P99 latency for the last N minutes.
func GetLatencyHistogram(ctx context.Context, minutes int) (map[string]any, error) {
if minutes <= 0 {
minutes = 5
}
if minutes > 60 {
minutes = 60
}
since := time.Now().Add(-time.Duration(minutes) * time.Minute).Format("2006-01-02 15:04:05")
type latRow struct {
P50 float64 `json:"p50"`
P95 float64 `json:"p95"`
P99 float64 `json:"p99"`
Avg float64 `json:"avg"`
Max float64 `json:"max"`
Min float64 `json:"min"`
}
var lat latRow
err := g.DB().Ctx(ctx).Raw(`
SELECT
COALESCE(percentile_cont(0.50) WITHIN GROUP (ORDER BY latency_ms), 0) as p50,
COALESCE(percentile_cont(0.95) WITHIN GROUP (ORDER BY latency_ms), 0) as p95,
COALESCE(percentile_cont(0.99) WITHIN GROUP (ORDER BY latency_ms), 0) as p99,
COALESCE(AVG(latency_ms), 0) as avg,
COALESCE(MAX(latency_ms), 0) as max,
COALESCE(MIN(latency_ms), 0) as min
FROM bil_usage_logs
WHERE created_at >= ? AND latency_ms IS NOT NULL
`, since).Scan(&lat)
if err != nil {
return nil, err
}
return map[string]any{
"p50": lat.P50,
"p95": lat.P95,
"p99": lat.P99,
"avg": lat.Avg,
"max": lat.Max,
"min": lat.Min,
}, nil
}
// GetModelDistribution returns model usage distribution for the last N minutes.
func GetModelDistribution(ctx context.Context, minutes int) ([]map[string]any, error) {
if minutes <= 0 {
minutes = 60
}
since := time.Now().Add(-time.Duration(minutes) * time.Minute).Format("2006-01-02 15:04:05")
result, err := g.DB().Ctx(ctx).Raw(`
SELECT
model_name,
COUNT(*) as requests,
COALESCE(SUM(COALESCE(input_tokens,0) + COALESCE(output_tokens,0)), 0) as tokens,
COALESCE(SUM(total_cost), 0) as total_cost
FROM bil_usage_logs
WHERE created_at >= ?
GROUP BY model_name
ORDER BY requests DESC
LIMIT 20
`, since).All()
if err != nil {
return nil, err
}
records := result.List()
return records, nil
}
// GetTenantRanking returns top tenants by request count for the last N minutes.
func GetTenantRanking(ctx context.Context, minutes int) ([]map[string]any, error) {
if minutes <= 0 {
minutes = 60
}
since := time.Now().Add(-time.Duration(minutes) * time.Minute).Format("2006-01-02 15:04:05")
result, err := g.DB().Ctx(ctx).Raw(`
SELECT
t.id as tenant_id,
t.name as tenant_name,
COUNT(*) as requests,
COALESCE(SUM(ul.total_cost), 0) as total_cost
FROM bil_usage_logs ul
JOIN tnt_tenants t ON t.id = ul.tenant_id
WHERE ul.created_at >= ?
GROUP BY t.id, t.name
ORDER BY requests DESC
LIMIT 10
`, since).All()
if err != nil {
return nil, err
}
records := result.List()
return records, nil
}
// GetErrorRate returns the current API error rate percentage.
func GetErrorRate(ctx context.Context) (float64, error) {
since := time.Now().Add(-5 * time.Minute).Format("2006-01-02 15:04:05")
type errRow struct {
Total int `json:"total"`
Errors int `json:"errors"`
}
var row errRow
err := dao.BilUsageLogs.Ctx(ctx).
Where("created_at >= ?", since).
Fields("COUNT(*) as total, COALESCE(SUM(CASE WHEN status != 'success' THEN 1 ELSE 0 END), 0) as errors").
Scan(&row)
if err != nil {
return 0, err
}
if row.Total == 0 {
return 0, nil
}
return float64(row.Errors) / float64(row.Total) * 100, nil
}
// GetP99Latency returns the current P99 latency in milliseconds.
func GetP99Latency(ctx context.Context) (float64, error) {
since := time.Now().Add(-5 * time.Minute).Format("2006-01-02 15:04:05")
type p99Row struct {
P99 float64 `json:"p99"`
}
var row p99Row
err := g.DB().Ctx(ctx).Raw(`
SELECT COALESCE(percentile_cont(0.99) WITHIN GROUP (ORDER BY latency_ms), 0) as p99
FROM bil_usage_logs
WHERE created_at >= ? AND latency_ms IS NOT NULL
`, since).Scan(&row)
if err != nil {
return 0, err
}
return row.P99, nil
}
// GetQPS returns the current requests per second.
func GetQPS(ctx context.Context) (float64, error) {
since := time.Now().Add(-1 * time.Minute).Format("2006-01-02 15:04:05")
count, err := dao.BilUsageLogs.Ctx(ctx).
Where("created_at >= ?", since).
Count()
if err != nil {
return 0, err
}
return float64(count) / 60.0, nil
}