| package sql |
|
|
| import ( |
| "context" |
| "database/sql" |
| "fmt" |
| "log" |
| "sort" |
| "strings" |
| "time" |
|
|
| "ccLoad/internal/model" |
| ) |
|
|
| |
| |
| func (s *SQLStore) executeStatsQuery(ctx context.Context, startTime, endTime time.Time, filter *model.LogFilter, withLastSuccess bool) (stats []model.StatsEntry, channelIDsToFetch map[int64]bool, err error) { |
| lastSuccessCol := "" |
| if withLastSuccess { |
| lastSuccessCol = `MAX(CASE WHEN status_code >= 200 AND status_code < 300 THEN time ELSE NULL END) AS last_success_at, |
| ` |
| } |
| baseQuery := ` |
| SELECT |
| channel_id, |
| COALESCE(model, '') AS model, |
| SUM(CASE WHEN status_code >= 200 AND status_code < 300 THEN 1 ELSE 0 END) AS success, |
| SUM(CASE WHEN (status_code < 200 OR status_code >= 300) AND status_code != 499 THEN 1 ELSE 0 END) AS error, |
| SUM(CASE WHEN status_code != 499 THEN 1 ELSE 0 END) AS total, |
| ROUND( |
| AVG(CASE WHEN is_streaming = 1 AND first_byte_time > 0 AND status_code >= 200 AND status_code < 300 THEN first_byte_time ELSE NULL END), |
| 3 |
| ) as avg_first_byte_time, |
| ROUND( |
| AVG(CASE WHEN duration > 0 THEN duration ELSE NULL END), |
| 3 |
| ) as avg_duration, |
| ` + lastSuccessCol + `SUM(COALESCE(input_tokens, 0)) as total_input_tokens, |
| SUM(COALESCE(output_tokens, 0)) as total_output_tokens, |
| SUM(COALESCE(cache_read_input_tokens, 0)) as total_cache_read_input_tokens, |
| SUM(COALESCE(cache_creation_input_tokens, 0)) as total_cache_creation_input_tokens, |
| SUM(COALESCE(cost, 0.0)) as total_cost, |
| SUM(COALESCE(cost, 0.0) * COALESCE(cost_multiplier, 1)) as effective_cost |
| FROM logs` |
|
|
| startMs := startTime.UnixMilli() |
| endMs := endTime.UnixMilli() |
|
|
| qb := NewQueryBuilder(baseQuery). |
| Where("time >= ?", startMs). |
| Where("time <= ?", endMs). |
| Where("channel_id > 0") |
|
|
| _, isEmpty, err := s.applyChannelFilter(ctx, qb, filter) |
| if err != nil { |
| return nil, nil, err |
| } |
| if isEmpty { |
| return []model.StatsEntry{}, map[int64]bool{}, nil |
| } |
|
|
| qb.ApplyFilter(filter) |
|
|
| suffix := "GROUP BY channel_id, model ORDER BY channel_id ASC, model ASC" |
| query, args := qb.BuildWithSuffix(suffix) |
|
|
| rows, err := s.db.QueryContext(ctx, query, args...) |
| if err != nil { |
| return nil, nil, err |
| } |
| defer func() { _ = rows.Close() }() |
|
|
| stats = make([]model.StatsEntry, 0) |
| channelIDsToFetch = make(map[int64]bool) |
|
|
| for rows.Next() { |
| var entry model.StatsEntry |
| var avgFirstByteTime, avgDuration sql.NullFloat64 |
| var lastSuccessAt sql.NullInt64 |
| var totalInputTokens, totalOutputTokens, totalCacheReadTokens, totalCacheCreationTokens sql.NullInt64 |
| var totalCost, effectiveCost sql.NullFloat64 |
|
|
| scanArgs := []any{ |
| &entry.ChannelID, &entry.Model, |
| &entry.Success, &entry.Error, &entry.Total, |
| &avgFirstByteTime, &avgDuration, |
| } |
| if withLastSuccess { |
| scanArgs = append(scanArgs, &lastSuccessAt) |
| } |
| scanArgs = append(scanArgs, |
| &totalInputTokens, &totalOutputTokens, &totalCacheReadTokens, &totalCacheCreationTokens, |
| &totalCost, &effectiveCost, |
| ) |
|
|
| if err := rows.Scan(scanArgs...); err != nil { |
| return nil, nil, err |
| } |
|
|
| if avgFirstByteTime.Valid { |
| entry.AvgFirstByteTimeSeconds = &avgFirstByteTime.Float64 |
| } |
| if avgDuration.Valid { |
| entry.AvgDurationSeconds = &avgDuration.Float64 |
| } |
| if withLastSuccess && lastSuccessAt.Valid && lastSuccessAt.Int64 > 0 { |
| entry.LastSuccessAt = &lastSuccessAt.Int64 |
| } |
|
|
| if totalInputTokens.Valid && totalInputTokens.Int64 > 0 { |
| entry.TotalInputTokens = &totalInputTokens.Int64 |
| } |
| if totalOutputTokens.Valid && totalOutputTokens.Int64 > 0 { |
| entry.TotalOutputTokens = &totalOutputTokens.Int64 |
| } |
| if totalCacheReadTokens.Valid && totalCacheReadTokens.Int64 > 0 { |
| entry.TotalCacheReadInputTokens = &totalCacheReadTokens.Int64 |
| } |
| if totalCacheCreationTokens.Valid && totalCacheCreationTokens.Int64 > 0 { |
| entry.TotalCacheCreationInputTokens = &totalCacheCreationTokens.Int64 |
| } |
| if totalCost.Valid && totalCost.Float64 > 0 { |
| entry.TotalCost = &totalCost.Float64 |
| } |
| if effectiveCost.Valid && (effectiveCost.Float64 > 0 || (totalCost.Valid && totalCost.Float64 > 0)) { |
| entry.EffectiveCost = &effectiveCost.Float64 |
| } |
|
|
| if entry.ChannelID != nil { |
| channelIDsToFetch[int64(*entry.ChannelID)] = true |
| } |
| stats = append(stats, entry) |
| } |
|
|
| if err := rows.Err(); err != nil { |
| return nil, nil, err |
| } |
| return stats, channelIDsToFetch, nil |
| } |
|
|
| |
| |
| |
| func (s *SQLStore) GetStats(ctx context.Context, startTime, endTime time.Time, filter *model.LogFilter, isToday bool) ([]model.StatsEntry, error) { |
| stats, channelIDsToFetch, err := s.executeStatsQuery(ctx, startTime, endTime, filter, true) |
| if err != nil { |
| return nil, err |
| } |
| if len(stats) == 0 { |
| return stats, nil |
| } |
|
|
| if len(channelIDsToFetch) > 0 { |
| channelInfos, err := s.fetchChannelInfoBatch(ctx, channelIDsToFetch) |
| if err != nil { |
| |
| log.Printf("[WARN] 批量查询渠道信息失败: %v", err) |
| channelInfos = make(map[int64]ChannelInfo) |
| } |
|
|
| |
| for i := range stats { |
| if stats[i].ChannelID != nil { |
| if info, ok := channelInfos[int64(*stats[i].ChannelID)]; ok { |
| stats[i].ChannelName = info.Name |
| stats[i].ChannelPriority = &info.Priority |
| stats[i].ChannelType = info.Type |
| if info.CostMultiplier != 1 { |
| costMultiplier := info.CostMultiplier |
| stats[i].CostMultiplier = &costMultiplier |
| } |
| } else { |
| |
| stats[i].ChannelName = "未知渠道" |
| } |
| } |
| } |
| } |
|
|
| if len(stats) > 0 { |
| if err := s.fillStatsLastSuccesses(ctx, stats, filter); err != nil { |
| log.Printf("[WARN] 查询渠道最后成功时间失败: %v", err) |
| } |
| if err := s.fillStatsLastRequests(ctx, stats, filter); err != nil { |
| log.Printf("[WARN] 查询渠道最近请求失败: %v", err) |
| } |
| } |
|
|
| |
| if len(stats) > 0 { |
| if err := s.fillStatsRPM(ctx, stats, startTime, endTime, filter, isToday); err != nil { |
| |
| log.Printf("[WARN] 计算RPM统计失败: %v", err) |
| } |
| } |
|
|
| return stats, nil |
| } |
|
|
| type statsRequestKey struct { |
| channelID int |
| model string |
| } |
|
|
| func cloneLogFilterWithoutStatusCode(filter *model.LogFilter) *model.LogFilter { |
| if filter == nil { |
| return nil |
| } |
|
|
| cloned := *filter |
| cloned.StatusCode = nil |
| return &cloned |
| } |
|
|
| func (s *SQLStore) fillStatsLastSuccesses(ctx context.Context, stats []model.StatsEntry, filter *model.LogFilter) error { |
| if hasStatsModelFilter(filter) { |
| return s.fillStatsLastSuccessesByEntry(ctx, stats, filter) |
| } |
|
|
| entryIndexesByChannel := make(map[int][]int, len(stats)) |
| for i := range stats { |
| if stats[i].ChannelID == nil { |
| continue |
| } |
| channelID := *stats[i].ChannelID |
| entryIndexesByChannel[channelID] = append(entryIndexesByChannel[channelID], i) |
| } |
| if len(entryIndexesByChannel) == 0 { |
| return nil |
| } |
|
|
| lastStateFilter := cloneLogFilterWithoutStatusCode(filter) |
|
|
| query, args := buildLatestChannelSuccessQuery(entryIndexesByChannel, lastStateFilter) |
| rows, err := s.db.QueryContext(ctx, query, args...) |
| if err != nil { |
| return err |
| } |
| defer func() { _ = rows.Close() }() |
|
|
| for rows.Next() { |
| var channelID int |
| var successAt int64 |
| var successID int64 |
| if err := rows.Scan(&channelID, &successAt, &successID); err != nil { |
| return err |
| } |
| if successAt <= 0 { |
| continue |
| } |
| for _, idx := range entryIndexesByChannel[channelID] { |
| successAtValue := successAt |
| stats[idx].LastSuccessAt = &successAtValue |
| if successID > 0 { |
| successIDValue := successID |
| stats[idx].LastSuccessID = &successIDValue |
| } |
| } |
| } |
| if err := rows.Err(); err != nil { |
| return err |
| } |
|
|
| return nil |
| } |
|
|
| func (s *SQLStore) fillStatsLastSuccessesByEntry(ctx context.Context, stats []model.StatsEntry, filter *model.LogFilter) error { |
| entryIndexes := make(map[statsRequestKey]int, len(stats)) |
| for i := range stats { |
| if stats[i].ChannelID == nil { |
| continue |
| } |
| key := statsRequestKey{channelID: *stats[i].ChannelID, model: stats[i].Model} |
| entryIndexes[key] = i |
| } |
| if len(entryIndexes) == 0 { |
| return nil |
| } |
|
|
| lastStateFilter := cloneLogFilterWithoutStatusCode(filter) |
|
|
| query, args := buildLatestEntrySuccessQuery(entryIndexes, lastStateFilter) |
| rows, err := s.db.QueryContext(ctx, query, args...) |
| if err != nil { |
| return err |
| } |
| defer func() { _ = rows.Close() }() |
|
|
| for rows.Next() { |
| var channelID int |
| var modelName string |
| var successAt int64 |
| var successID int64 |
| if err := rows.Scan(&channelID, &modelName, &successAt, &successID); err != nil { |
| return err |
| } |
| if successAt <= 0 { |
| continue |
| } |
| key := statsRequestKey{channelID: channelID, model: modelName} |
| idx, ok := entryIndexes[key] |
| if !ok { |
| continue |
| } |
| stats[idx].LastSuccessAt = &successAt |
| if successID > 0 { |
| stats[idx].LastSuccessID = &successID |
| } |
| } |
| if err := rows.Err(); err != nil { |
| return err |
| } |
|
|
| return nil |
| } |
|
|
| func (s *SQLStore) fillStatsLastRequests(ctx context.Context, stats []model.StatsEntry, filter *model.LogFilter) error { |
| if hasStatsModelFilter(filter) { |
| return s.fillStatsLastRequestsByEntry(ctx, stats, filter) |
| } |
|
|
| entryIndexesByChannel := make(map[int][]int, len(stats)) |
| for i := range stats { |
| if stats[i].ChannelID == nil { |
| continue |
| } |
| channelID := *stats[i].ChannelID |
| entryIndexesByChannel[channelID] = append(entryIndexesByChannel[channelID], i) |
| } |
| if len(entryIndexesByChannel) == 0 { |
| return nil |
| } |
|
|
| lastStateFilter := cloneLogFilterWithoutStatusCode(filter) |
|
|
| query, args := buildLatestChannelRequestQuery(entryIndexesByChannel, lastStateFilter) |
| rows, err := s.db.QueryContext(ctx, query, args...) |
| if err != nil { |
| return err |
| } |
| defer func() { _ = rows.Close() }() |
|
|
| for rows.Next() { |
| var channelID int |
| var requestAt int64 |
| var requestID int64 |
| var status int |
| var message string |
| if err := rows.Scan(&channelID, &requestAt, &requestID, &status, &message); err != nil { |
| return err |
| } |
| if requestAt <= 0 { |
| continue |
| } |
| for _, idx := range entryIndexesByChannel[channelID] { |
| requestAtValue := requestAt |
| stats[idx].LastRequestAt = &requestAtValue |
| if requestID > 0 { |
| requestIDValue := requestID |
| stats[idx].LastRequestID = &requestIDValue |
| } |
| statusValue := status |
| stats[idx].LastRequestStatus = &statusValue |
| stats[idx].LastRequestMessage = message |
| } |
| } |
| if err := rows.Err(); err != nil { |
| return err |
| } |
|
|
| return nil |
| } |
|
|
| func (s *SQLStore) fillStatsLastRequestsByEntry(ctx context.Context, stats []model.StatsEntry, filter *model.LogFilter) error { |
| entryIndexes := make(map[statsRequestKey]int, len(stats)) |
| for i := range stats { |
| if stats[i].ChannelID == nil { |
| continue |
| } |
| key := statsRequestKey{channelID: *stats[i].ChannelID, model: stats[i].Model} |
| entryIndexes[key] = i |
| } |
| if len(entryIndexes) == 0 { |
| return nil |
| } |
|
|
| lastStateFilter := cloneLogFilterWithoutStatusCode(filter) |
|
|
| query, args := buildLatestEntryRequestQuery(entryIndexes, lastStateFilter) |
| rows, err := s.db.QueryContext(ctx, query, args...) |
| if err != nil { |
| return err |
| } |
| defer func() { _ = rows.Close() }() |
|
|
| for rows.Next() { |
| var channelID int |
| var modelName string |
| var requestAt int64 |
| var requestID int64 |
| var status int |
| var message string |
| if err := rows.Scan(&channelID, &modelName, &requestAt, &requestID, &status, &message); err != nil { |
| return err |
| } |
| if requestAt <= 0 { |
| continue |
| } |
| key := statsRequestKey{channelID: channelID, model: modelName} |
| idx, ok := entryIndexes[key] |
| if !ok { |
| continue |
| } |
| stats[idx].LastRequestAt = &requestAt |
| if requestID > 0 { |
| stats[idx].LastRequestID = &requestID |
| } |
| stats[idx].LastRequestStatus = &status |
| stats[idx].LastRequestMessage = message |
| } |
| if err := rows.Err(); err != nil { |
| return err |
| } |
|
|
| return nil |
| } |
|
|
| func hasStatsModelFilter(filter *model.LogFilter) bool { |
| return filter != nil && (filter.Model != "" || filter.ModelLike != "") |
| } |
|
|
| func buildLatestChannelSuccessQuery(entryIndexesByChannel map[int][]int, filter *model.LogFilter) (string, []any) { |
| return buildLatestChannelLogQuery(entryIndexesByChannel, filter, []string{"l.time", "l.id"}, func(qb *QueryBuilder) { |
| qb.Where("status_code >= 200"). |
| Where("status_code < 300") |
| }) |
| } |
|
|
| func buildLatestChannelRequestQuery(entryIndexesByChannel map[int][]int, filter *model.LogFilter) (string, []any) { |
| return buildLatestChannelLogQuery(entryIndexesByChannel, filter, []string{"l.time", "l.id", "l.status_code", "l.message"}, func(qb *QueryBuilder) { |
| qb.Where("status_code != 499") |
| }) |
| } |
|
|
| func buildLatestEntrySuccessQuery(entryIndexes map[statsRequestKey]int, filter *model.LogFilter) (string, []any) { |
| return buildLatestEntryLogQuery(entryIndexes, filter, []string{"l.time", "l.id"}, func(qb *QueryBuilder) { |
| qb.Where("status_code >= 200"). |
| Where("status_code < 300") |
| }) |
| } |
|
|
| func buildLatestEntryRequestQuery(entryIndexes map[statsRequestKey]int, filter *model.LogFilter) (string, []any) { |
| return buildLatestEntryLogQuery(entryIndexes, filter, []string{"l.time", "l.id", "l.status_code", "l.message"}, func(qb *QueryBuilder) { |
| qb.Where("status_code != 499") |
| }) |
| } |
|
|
| func buildLatestChannelLogQuery(entryIndexesByChannel map[int][]int, filter *model.LogFilter, selectColumns []string, applyStatePredicate func(*QueryBuilder)) (string, []any) { |
| scopeSQL, scopeArgs := buildChannelScope(entryIndexesByChannel) |
|
|
| subQB := NewQueryBuilder("SELECT id FROM logs"). |
| Where("channel_id = scope.channel_id"). |
| Where("channel_id > 0") |
| applyStatePredicate(subQB) |
| subQB.ApplyFilter(filter) |
| subQuery, subArgs := subQB.BuildWithSuffix("ORDER BY time DESC, id DESC LIMIT 1") |
|
|
| query := fmt.Sprintf(` |
| SELECT scope.channel_id, %s |
| FROM (%s) scope |
| JOIN logs l ON l.id = (%s)`, strings.Join(selectColumns, ", "), scopeSQL, subQuery) |
|
|
| args := make([]any, 0, len(scopeArgs)+len(subArgs)) |
| args = append(args, scopeArgs...) |
| args = append(args, subArgs...) |
| return query, args |
| } |
|
|
| func buildLatestEntryLogQuery(entryIndexes map[statsRequestKey]int, filter *model.LogFilter, selectColumns []string, applyStatePredicate func(*QueryBuilder)) (string, []any) { |
| scopeSQL, scopeArgs := buildEntryScope(entryIndexes) |
|
|
| subQB := NewQueryBuilder("SELECT id FROM logs"). |
| Where("channel_id = scope.channel_id"). |
| Where("COALESCE(model, '') = scope.model"). |
| Where("channel_id > 0") |
| applyStatePredicate(subQB) |
| subQB.ApplyFilter(filter) |
| subQuery, subArgs := subQB.BuildWithSuffix("ORDER BY time DESC, id DESC LIMIT 1") |
|
|
| query := fmt.Sprintf(` |
| SELECT scope.channel_id, scope.model, %s |
| FROM (%s) scope |
| JOIN logs l ON l.id = (%s)`, strings.Join(selectColumns, ", "), scopeSQL, subQuery) |
|
|
| args := make([]any, 0, len(scopeArgs)+len(subArgs)) |
| args = append(args, scopeArgs...) |
| args = append(args, subArgs...) |
| return query, args |
| } |
|
|
| func buildChannelScope(entryIndexesByChannel map[int][]int) (string, []any) { |
| channelIDs := make([]int, 0, len(entryIndexesByChannel)) |
| for channelID := range entryIndexesByChannel { |
| channelIDs = append(channelIDs, channelID) |
| } |
| sort.Ints(channelIDs) |
|
|
| selects := make([]string, 0, len(channelIDs)) |
| args := make([]any, 0, len(channelIDs)) |
| for i, channelID := range channelIDs { |
| if i == 0 { |
| selects = append(selects, "SELECT ? AS channel_id") |
| } else { |
| selects = append(selects, "SELECT ?") |
| } |
| args = append(args, channelID) |
| } |
| return strings.Join(selects, " UNION ALL "), args |
| } |
|
|
| func buildEntryScope(entryIndexes map[statsRequestKey]int) (string, []any) { |
| keys := make([]statsRequestKey, 0, len(entryIndexes)) |
| for key := range entryIndexes { |
| keys = append(keys, key) |
| } |
| sort.Slice(keys, func(i, j int) bool { |
| if keys[i].channelID != keys[j].channelID { |
| return keys[i].channelID < keys[j].channelID |
| } |
| return keys[i].model < keys[j].model |
| }) |
|
|
| selects := make([]string, 0, len(keys)) |
| args := make([]any, 0, len(entryIndexes)*2) |
| for i, key := range keys { |
| if i == 0 { |
| selects = append(selects, "SELECT ? AS channel_id, ? AS model") |
| } else { |
| selects = append(selects, "SELECT ?, ?") |
| } |
| args = append(args, key.channelID, key.model) |
| } |
| return strings.Join(selects, " UNION ALL "), args |
| } |
|
|
| |
| |
| func (s *SQLStore) GetStatsLite(ctx context.Context, startTime, endTime time.Time, filter *model.LogFilter) ([]model.StatsEntry, error) { |
| stats, _, err := s.executeStatsQuery(ctx, startTime, endTime, filter, false) |
| return stats, err |
| } |
|
|
| |
| |
| |
| func (s *SQLStore) GetRPMStats(ctx context.Context, startTime, endTime time.Time, filter *model.LogFilter, isToday bool) (*model.RPMStats, error) { |
| stats := &model.RPMStats{} |
|
|
| startBucket := startTime.UnixMilli() / minuteMs |
| endBucket := endTime.UnixMilli() / minuteMs |
|
|
| |
| |
| |
| combinedBaseQuery := ` |
| SELECT COALESCE(MAX(cnt), 0) as peak_rpm, COALESCE(SUM(cnt), 0) as total_count FROM ( |
| SELECT COUNT(*) as cnt |
| FROM logs` |
|
|
| combinedQB := NewQueryBuilder(combinedBaseQuery). |
| Where("minute_bucket >= ?", startBucket). |
| Where("minute_bucket <= ?", endBucket). |
| Where("channel_id > 0"). |
| Where("status_code != 499") |
|
|
| |
| _, isEmpty, err := s.applyChannelFilter(ctx, combinedQB, filter) |
| if err != nil { |
| return nil, fmt.Errorf("apply channel filter: %w", err) |
| } |
| if isEmpty { |
| return stats, nil |
| } |
|
|
| |
| combinedQB.ApplyFilter(filter) |
|
|
| combinedQuery, combinedArgs := combinedQB.BuildWithSuffix("GROUP BY minute_bucket) t") |
|
|
| var peakRPM float64 |
| var totalCount int64 |
| if err := s.db.QueryRowContext(ctx, combinedQuery, combinedArgs...).Scan(&peakRPM, &totalCount); err != nil { |
| return nil, fmt.Errorf("query peak RPM and total: %w", err) |
| } |
| stats.PeakRPM = peakRPM |
| stats.PeakQPS = peakRPM / 60 |
|
|
| |
| durationSeconds := endTime.Sub(startTime).Seconds() |
| if durationSeconds < 1 { |
| durationSeconds = 1 |
| } |
| stats.AvgRPM = float64(totalCount) * 60 / durationSeconds |
| stats.AvgQPS = float64(totalCount) / durationSeconds |
|
|
| |
| if isToday { |
| now := time.Now() |
| recentStartBucket := now.Add(-60*time.Second).UnixMilli() / minuteMs |
| recentEndBucket := now.UnixMilli() / minuteMs |
|
|
| recentBaseQuery := `SELECT COUNT(*) FROM logs` |
| recentQB := NewQueryBuilder(recentBaseQuery). |
| Where("minute_bucket >= ?", recentStartBucket). |
| Where("minute_bucket <= ?", recentEndBucket). |
| Where("channel_id > 0"). |
| Where("status_code != 499") |
|
|
| |
| _, isEmpty, err = s.applyChannelFilter(ctx, recentQB, filter) |
| if err != nil { |
| return nil, fmt.Errorf("apply channel filter for recent: %w", err) |
| } |
| if !isEmpty { |
| |
| recentQB.ApplyFilter(filter) |
|
|
| recentQuery, recentArgs := recentQB.Build() |
| var recentCount int64 |
| if err := s.db.QueryRowContext(ctx, recentQuery, recentArgs...).Scan(&recentCount); err != nil { |
| return nil, fmt.Errorf("query recent count: %w", err) |
| } |
|
|
| stats.RecentRPM = float64(recentCount) |
| stats.RecentQPS = float64(recentCount) / 60 |
|
|
| |
| if stats.RecentRPM > stats.PeakRPM { |
| stats.PeakRPM = stats.RecentRPM |
| stats.PeakQPS = stats.RecentQPS |
| } |
| } |
| } |
|
|
| return stats, nil |
| } |
|
|
| |
| |
| func (s *SQLStore) fillStatsRPM(ctx context.Context, stats []model.StatsEntry, startTime, endTime time.Time, filter *model.LogFilter, isToday bool) error { |
| startBucket := startTime.UnixMilli() / minuteMs |
| endBucket := endTime.UnixMilli() / minuteMs |
|
|
| |
| durationSeconds := endTime.Sub(startTime).Seconds() |
| if durationSeconds < 1 { |
| durationSeconds = 1 |
| } |
|
|
| type statsKey struct { |
| channelID int |
| model string |
| } |
| peakRPMMap := make(map[statsKey]float64) |
|
|
| |
| peakBaseQuery := ` |
| SELECT channel_id, COALESCE(model, '') AS model, MAX(cnt) AS peak_rpm |
| FROM ( |
| SELECT channel_id, COALESCE(model, '') AS model, COUNT(*) AS cnt |
| FROM logs` |
|
|
| peakQB := NewQueryBuilder(peakBaseQuery). |
| Where("minute_bucket >= ?", startBucket). |
| Where("minute_bucket <= ?", endBucket). |
| Where("channel_id > 0"). |
| Where("status_code != 499") |
|
|
| _, isEmpty, err := s.applyChannelFilter(ctx, peakQB, filter) |
| if err != nil { |
| return fmt.Errorf("apply channel filter for peak: %w", err) |
| } |
|
|
| |
| if !isEmpty { |
| peakQB.ApplyFilter(filter) |
| peakQuery, peakArgs := peakQB.BuildWithSuffix("GROUP BY channel_id, model, minute_bucket) t GROUP BY channel_id, model") |
|
|
| peakRows, err := s.db.QueryContext(ctx, peakQuery, peakArgs...) |
| if err != nil { |
| return fmt.Errorf("query peak RPM: %w", err) |
| } |
| defer func() { _ = peakRows.Close() }() |
|
|
| for peakRows.Next() { |
| var channelID int |
| var model string |
| var peakRPM float64 |
| if err := peakRows.Scan(&channelID, &model, &peakRPM); err != nil { |
| return fmt.Errorf("scan peak RPM: %w", err) |
| } |
| peakRPMMap[statsKey{channelID, model}] = peakRPM |
| } |
| if err := peakRows.Err(); err != nil { |
| return fmt.Errorf("iterate peak RPM rows: %w", err) |
| } |
| } |
|
|
| |
| recentRPMMap := make(map[statsKey]float64) |
| if isToday { |
| now := time.Now() |
| recentStartBucket := now.Add(-60*time.Second).UnixMilli() / minuteMs |
| recentEndBucket := now.UnixMilli() / minuteMs |
|
|
| recentBaseQuery := ` |
| SELECT channel_id, COALESCE(model, '') AS model, COUNT(*) AS cnt |
| FROM logs` |
| recentQB := NewQueryBuilder(recentBaseQuery). |
| Where("minute_bucket >= ?", recentStartBucket). |
| Where("minute_bucket <= ?", recentEndBucket). |
| Where("channel_id > 0"). |
| Where("status_code != 499") |
|
|
| _, isEmpty, err := s.applyChannelFilter(ctx, recentQB, filter) |
| if err != nil { |
| return fmt.Errorf("apply channel filter for recent: %w", err) |
| } |
|
|
| |
| if !isEmpty { |
| recentQB.ApplyFilter(filter) |
| recentQuery, recentArgs := recentQB.BuildWithSuffix("GROUP BY channel_id, model") |
| recentRows, err := s.db.QueryContext(ctx, recentQuery, recentArgs...) |
| if err != nil { |
| return fmt.Errorf("query recent RPM: %w", err) |
| } |
| defer func() { _ = recentRows.Close() }() |
|
|
| for recentRows.Next() { |
| var channelID int |
| var model string |
| var cnt float64 |
| if err := recentRows.Scan(&channelID, &model, &cnt); err != nil { |
| return fmt.Errorf("scan recent RPM: %w", err) |
| } |
| recentRPMMap[statsKey{channelID, model}] = cnt |
| } |
| if err := recentRows.Err(); err != nil { |
| return fmt.Errorf("iterate recent RPM rows: %w", err) |
| } |
| } |
| } |
|
|
| |
| for i := range stats { |
| entry := &stats[i] |
| if entry.ChannelID == nil { |
| continue |
| } |
|
|
| key := statsKey{*entry.ChannelID, entry.Model} |
|
|
| if peakRPM, ok := peakRPMMap[key]; ok && peakRPM > 0 { |
| entry.PeakRPM = &peakRPM |
| } |
|
|
| if entry.Total > 0 { |
| avgRPM := float64(entry.Total) * 60 / durationSeconds |
| entry.AvgRPM = &avgRPM |
| } |
|
|
| if isToday { |
| if recentRPM, ok := recentRPMMap[key]; ok && recentRPM > 0 { |
| entry.RecentRPM = &recentRPM |
| if entry.PeakRPM == nil || *entry.PeakRPM < recentRPM { |
| entry.PeakRPM = &recentRPM |
| } |
| } |
| } |
| } |
|
|
| return nil |
| } |
|
|
| |
| |
| func (s *SQLStore) GetChannelSuccessRates(ctx context.Context, since time.Time) (map[int64]model.ChannelHealthStats, error) { |
| sinceBucket := since.UnixMilli() / minuteMs |
| untilBucket := time.Now().UnixMilli() / minuteMs |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| eligible := ` |
| (status_code >= 200 AND status_code < 300) |
| OR status_code IN (401, 402, 403, 405, 429, 500, 502, 503, 504, 520, 521, 524, 597, 598, 599) |
| ` |
|
|
| |
| |
| query := ` |
| SELECT |
| channel_id, |
| SUM(CASE WHEN status_code >= 200 AND status_code < 300 THEN 1 ELSE 0 END) AS success, |
| SUM(CASE WHEN ` + eligible + ` THEN 1 ELSE 0 END) AS total |
| FROM logs |
| WHERE minute_bucket >= ? AND minute_bucket <= ? AND channel_id > 0 AND log_source = ? |
| GROUP BY channel_id` |
|
|
| rows, err := s.db.QueryContext(ctx, query, sinceBucket, untilBucket, model.LogSourceProxy) |
| if err != nil { |
| return nil, err |
| } |
| defer func() { _ = rows.Close() }() |
|
|
| result := make(map[int64]model.ChannelHealthStats) |
| for rows.Next() { |
| var channelID int64 |
| var success, total int64 |
| if err := rows.Scan(&channelID, &success, &total); err != nil { |
| return nil, err |
| } |
| if total > 0 { |
| result[channelID] = model.ChannelHealthStats{ |
| SuccessRate: float64(success) / float64(total), |
| SampleCount: total, |
| } |
| } |
| } |
|
|
| return result, rows.Err() |
| } |
|
|
| |
| |
| func (s *SQLStore) GetTodayChannelCosts(ctx context.Context, todayStart time.Time) (map[int64]float64, error) { |
| todayStartMs := todayStart.UnixMilli() |
|
|
| query := ` |
| SELECT channel_id, COALESCE(SUM(COALESCE(cost, 0.0) * COALESCE(cost_multiplier, 1)), 0) as total_cost |
| FROM logs |
| WHERE time >= ? AND channel_id > 0 AND log_source = ? |
| GROUP BY channel_id` |
|
|
| rows, err := s.db.QueryContext(ctx, query, todayStartMs, model.LogSourceProxy) |
| if err != nil { |
| return nil, err |
| } |
| defer func() { _ = rows.Close() }() |
|
|
| result := make(map[int64]float64) |
| for rows.Next() { |
| var channelID int64 |
| var totalCost float64 |
| if err := rows.Scan(&channelID, &totalCost); err != nil { |
| return nil, err |
| } |
| result[channelID] = totalCost |
| } |
|
|
| return result, rows.Err() |
| } |
|
|