mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-01 15:02:58 +08:00
feat(channel-monitor-v2): 实现被动聚合、分层保留与只读 API
基于 usage_logs/ops_error_logs 做分钟聚合与 5m/1h/12h/1d rollup,提供 snapshot/matrix/errors/users 等接口;V1 在 mode=v2 时停用主动探测, 并对用户端按需脱敏绝对量与 RPM/TPM。
This commit is contained in:
@@ -0,0 +1,204 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type ChannelMonitorV2Handler struct {
|
||||
service *service.ChannelMonitorV2Service
|
||||
}
|
||||
|
||||
func NewChannelMonitorV2Handler(svc *service.ChannelMonitorV2Service) *ChannelMonitorV2Handler {
|
||||
return &ChannelMonitorV2Handler{service: svc}
|
||||
}
|
||||
|
||||
// channelMonitorV2IsAdmin is true when the request already passed admin auth
|
||||
// (shared Dimensions/Errors handlers serve both user and admin route groups).
|
||||
func channelMonitorV2IsAdmin(c *gin.Context) bool {
|
||||
role, ok := middleware.GetUserRoleFromContext(c)
|
||||
return ok && role == service.RoleAdmin
|
||||
}
|
||||
|
||||
func (h *ChannelMonitorV2Handler) GetConfig(c *gin.Context) {
|
||||
cfg, err := h.service.GetConfig(c.Request.Context())
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, cfg)
|
||||
}
|
||||
|
||||
func (h *ChannelMonitorV2Handler) UpdateConfig(c *gin.Context) {
|
||||
var input service.ChannelMonitorV2Config
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
response.BadRequest(c, "invalid channel monitor v2 config")
|
||||
return
|
||||
}
|
||||
subject, ok := middleware.GetAuthSubjectFromContext(c)
|
||||
if !ok || subject.UserID <= 0 {
|
||||
response.Unauthorized(c, "user not found in context")
|
||||
return
|
||||
}
|
||||
updated, err := h.service.UpdateConfig(c.Request.Context(), input, input.Version, subject.UserID)
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrChannelMonitorV2ConfigConflict) {
|
||||
response.Error(c, http.StatusConflict, err.Error())
|
||||
return
|
||||
}
|
||||
if errors.Is(err, service.ErrChannelMonitorV2InvalidConfig) {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, updated)
|
||||
}
|
||||
|
||||
func (h *ChannelMonitorV2Handler) Dimensions(c *gin.Context) {
|
||||
filter, ok := h.parseFilter(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
result, err := h.service.Dimensions(c.Request.Context(), filter)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
// Admin and user share this handler; only non-admin responses strip volume.
|
||||
if !channelMonitorV2IsAdmin(c) {
|
||||
service.RedactChannelMonitorV2Dimensions(result)
|
||||
}
|
||||
response.Success(c, result)
|
||||
}
|
||||
|
||||
func (h *ChannelMonitorV2Handler) Snapshot(c *gin.Context) { h.snapshot(c, false) }
|
||||
func (h *ChannelMonitorV2Handler) AdminSnapshot(c *gin.Context) { h.snapshot(c, true) }
|
||||
func (h *ChannelMonitorV2Handler) Models(c *gin.Context) { h.models(c, false) }
|
||||
func (h *ChannelMonitorV2Handler) AdminModels(c *gin.Context) { h.models(c, true) }
|
||||
func (h *ChannelMonitorV2Handler) Matrix(c *gin.Context) { h.matrix(c, false) }
|
||||
func (h *ChannelMonitorV2Handler) AdminMatrix(c *gin.Context) { h.matrix(c, true) }
|
||||
func (h *ChannelMonitorV2Handler) Users(c *gin.Context) { h.users(c, false) }
|
||||
func (h *ChannelMonitorV2Handler) AdminUsers(c *gin.Context) { h.users(c, true) }
|
||||
|
||||
func (h *ChannelMonitorV2Handler) snapshot(c *gin.Context, admin bool) {
|
||||
filter, ok := h.parseFilter(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
result, err := h.service.Snapshot(c.Request.Context(), filter, admin)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, result)
|
||||
}
|
||||
|
||||
func (h *ChannelMonitorV2Handler) models(c *gin.Context, admin bool) {
|
||||
filter, ok := h.parseFilter(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
result, err := h.service.Models(c.Request.Context(), filter, admin)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, result)
|
||||
}
|
||||
|
||||
func (h *ChannelMonitorV2Handler) matrix(c *gin.Context, admin bool) {
|
||||
filter, ok := h.parseFilter(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
groupBy, err := service.ParseChannelMonitorV2GroupBy(c.Query("group_by"))
|
||||
if err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
result, err := h.service.Matrix(c.Request.Context(), filter, groupBy, admin)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, result)
|
||||
}
|
||||
|
||||
func (h *ChannelMonitorV2Handler) Errors(c *gin.Context) {
|
||||
filter, ok := h.parseFilter(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
result, err := h.service.ErrorsForViewer(c.Request.Context(), filter, channelMonitorV2IsAdmin(c))
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, result)
|
||||
}
|
||||
|
||||
func (h *ChannelMonitorV2Handler) users(c *gin.Context, admin bool) {
|
||||
filter, ok := h.parseFilter(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
subject, exists := middleware.GetAuthSubjectFromContext(c)
|
||||
if !exists {
|
||||
response.Error(c, http.StatusUnauthorized, "user not found in context")
|
||||
return
|
||||
}
|
||||
result, err := h.service.Users(c.Request.Context(), filter, subject.UserID, admin)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, result)
|
||||
}
|
||||
|
||||
func (h *ChannelMonitorV2Handler) parseFilter(c *gin.Context) (service.ChannelMonitorV2Filter, bool) {
|
||||
groups, err := parseChannelMonitorV2GroupIDs(queryList(c, "group_id"))
|
||||
if err != nil {
|
||||
response.BadRequest(c, "invalid group_id")
|
||||
return service.ChannelMonitorV2Filter{}, false
|
||||
}
|
||||
filter, err := h.service.ParseFilter(c.Query("range"), queryList(c, "platform"), queryList(c, "model"), groups)
|
||||
if err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return service.ChannelMonitorV2Filter{}, false
|
||||
}
|
||||
return filter, true
|
||||
}
|
||||
|
||||
func queryList(c *gin.Context, key string) []string {
|
||||
values := c.QueryArray(key)
|
||||
result := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
for _, part := range strings.Split(value, ",") {
|
||||
if part = strings.TrimSpace(part); part != "" {
|
||||
result = append(result, part)
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func parseChannelMonitorV2GroupIDs(values []string) ([]int64, error) {
|
||||
result := make([]int64, 0, len(values))
|
||||
for _, value := range values {
|
||||
id, err := strconv.ParseInt(value, 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
return nil, errors.New("invalid group id")
|
||||
}
|
||||
result = append(result, id)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestChannelMonitorV2QueryListSupportsRepeatedAndCommaValues(t *testing.T) {
|
||||
c, _ := gin.CreateTestContext(nil)
|
||||
c.Request = httptest.NewRequest("GET", "/?platform=openai,grok&platform=anthropic", nil)
|
||||
require.Equal(t, []string{"openai", "grok", "anthropic"}, queryList(c, "platform"))
|
||||
}
|
||||
|
||||
func TestChannelMonitorV2GroupByQueryDefaultsAndRejectsInvalid(t *testing.T) {
|
||||
groupBy, err := service.ParseChannelMonitorV2GroupBy("")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, service.ChannelMonitorV2GroupByPlatformGroup, groupBy)
|
||||
_, err = service.ParseChannelMonitorV2GroupBy("invalid")
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestChannelMonitorV2MatrixHandlerRejectsInvalidGroupBy(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/channel-monitor-v2/matrix?group_by=invalid", nil)
|
||||
h := NewChannelMonitorV2Handler(service.NewChannelMonitorV2Service(nil))
|
||||
h.Matrix(c)
|
||||
require.Equal(t, http.StatusBadRequest, recorder.Code)
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Platform is derived from group/account (usage_logs has no provider column on upstream schema).
|
||||
const channelMonitorV2PlatformSQL = `lower(COALESCE(NULLIF(TRIM(g.platform), ''), NULLIF(TRIM(a.platform), ''), 'unknown'))`
|
||||
const channelMonitorV2ModelSQL = `COALESCE(NULLIF(TRIM(ul.requested_model), ''), NULLIF(TRIM(ul.model), ''), 'unknown')`
|
||||
|
||||
// Tiered retention balances UI windows against storage:
|
||||
//
|
||||
// 1m facts → short (late writes + rebuild rollups)
|
||||
// 5m/1h/12h/1d rollups → longer, aligned to 90m / 24h / 7d / 30d(+audit)
|
||||
//
|
||||
// Backfill may still write short-lived 1m rows for old windows so rollups can be
|
||||
// built; prune at end of each recompute drops them past their TTL while rollups remain.
|
||||
const (
|
||||
channelMonitorV2RetentionUser1m = 3 * 24 * time.Hour
|
||||
channelMonitorV2RetentionMetrics1m = 7 * 24 * time.Hour
|
||||
channelMonitorV2RetentionError1m = 7 * 24 * time.Hour
|
||||
channelMonitorV2RetentionHistogram1m = 7 * 24 * time.Hour
|
||||
channelMonitorV2RetentionRollup5m = 7 * 24 * time.Hour // bucket_seconds=300
|
||||
channelMonitorV2RetentionRollup1h = 30 * 24 * time.Hour // 3600
|
||||
channelMonitorV2RetentionRollup12h = 45 * 24 * time.Hour // 43200
|
||||
channelMonitorV2RetentionRollup1d = 90 * 24 * time.Hour // 86400
|
||||
channelMonitorV2RetentionMax = channelMonitorV2RetentionRollup1d
|
||||
)
|
||||
|
||||
// channelMonitorV2MaxRetention is the longest stored window (1d rollup). Used to
|
||||
// clamp recompute/backfill so we never scan older than product history needs.
|
||||
func channelMonitorV2MaxRetention() time.Duration {
|
||||
return channelMonitorV2RetentionMax
|
||||
}
|
||||
|
||||
func channelMonitorV2RetentionCutoff(now time.Time, retention time.Duration) time.Time {
|
||||
return now.UTC().Truncate(time.Minute).Add(-retention)
|
||||
}
|
||||
|
||||
type channelMonitorV2RetentionRule struct {
|
||||
table string
|
||||
retention time.Duration
|
||||
bucketSeconds int // 0 = fact table (no bucket_seconds column)
|
||||
}
|
||||
|
||||
// channelMonitorV2RetentionRules is ordered coarse→fine for predictable prune plans.
|
||||
var channelMonitorV2RetentionRules = []channelMonitorV2RetentionRule{
|
||||
{table: "channel_monitor_v2_user_metrics_1m", retention: channelMonitorV2RetentionUser1m},
|
||||
{table: "channel_monitor_v2_metrics_1m", retention: channelMonitorV2RetentionMetrics1m},
|
||||
{table: "channel_monitor_v2_error_metrics_1m", retention: channelMonitorV2RetentionError1m},
|
||||
{table: "channel_monitor_v2_latency_histograms_1m", retention: channelMonitorV2RetentionHistogram1m},
|
||||
{table: "channel_monitor_v2_metrics_rollup", retention: channelMonitorV2RetentionRollup5m, bucketSeconds: 300},
|
||||
{table: "channel_monitor_v2_user_metrics_rollup", retention: channelMonitorV2RetentionRollup5m, bucketSeconds: 300},
|
||||
{table: "channel_monitor_v2_error_metrics_rollup", retention: channelMonitorV2RetentionRollup5m, bucketSeconds: 300},
|
||||
{table: "channel_monitor_v2_latency_histograms_rollup", retention: channelMonitorV2RetentionRollup5m, bucketSeconds: 300},
|
||||
{table: "channel_monitor_v2_metrics_rollup", retention: channelMonitorV2RetentionRollup1h, bucketSeconds: 3600},
|
||||
{table: "channel_monitor_v2_user_metrics_rollup", retention: channelMonitorV2RetentionRollup1h, bucketSeconds: 3600},
|
||||
{table: "channel_monitor_v2_error_metrics_rollup", retention: channelMonitorV2RetentionRollup1h, bucketSeconds: 3600},
|
||||
{table: "channel_monitor_v2_latency_histograms_rollup", retention: channelMonitorV2RetentionRollup1h, bucketSeconds: 3600},
|
||||
{table: "channel_monitor_v2_metrics_rollup", retention: channelMonitorV2RetentionRollup12h, bucketSeconds: 43200},
|
||||
{table: "channel_monitor_v2_user_metrics_rollup", retention: channelMonitorV2RetentionRollup12h, bucketSeconds: 43200},
|
||||
{table: "channel_monitor_v2_error_metrics_rollup", retention: channelMonitorV2RetentionRollup12h, bucketSeconds: 43200},
|
||||
{table: "channel_monitor_v2_latency_histograms_rollup", retention: channelMonitorV2RetentionRollup12h, bucketSeconds: 43200},
|
||||
{table: "channel_monitor_v2_metrics_rollup", retention: channelMonitorV2RetentionRollup1d, bucketSeconds: 86400},
|
||||
{table: "channel_monitor_v2_user_metrics_rollup", retention: channelMonitorV2RetentionRollup1d, bucketSeconds: 86400},
|
||||
{table: "channel_monitor_v2_error_metrics_rollup", retention: channelMonitorV2RetentionRollup1d, bucketSeconds: 86400},
|
||||
{table: "channel_monitor_v2_latency_histograms_rollup", retention: channelMonitorV2RetentionRollup1d, bucketSeconds: 86400},
|
||||
}
|
||||
|
||||
func (r *channelMonitorV2Repository) pruneChannelMonitorV2Retention(ctx context.Context, tx *sql.Tx, now time.Time) error {
|
||||
for _, rule := range channelMonitorV2RetentionRules {
|
||||
cutoff := channelMonitorV2RetentionCutoff(now, rule.retention)
|
||||
var err error
|
||||
if rule.bucketSeconds == 0 {
|
||||
_, err = tx.ExecContext(ctx, fmt.Sprintf(`DELETE FROM %s WHERE bucket_start < $1`, rule.table), cutoff)
|
||||
} else {
|
||||
_, err = tx.ExecContext(ctx,
|
||||
fmt.Sprintf(`DELETE FROM %s WHERE bucket_seconds = $1 AND bucket_start < $2`, rule.table),
|
||||
rule.bucketSeconds, cutoff,
|
||||
)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("prune %s (bucket_seconds=%d): %w", rule.table, rule.bucketSeconds, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *channelMonitorV2Repository) RecomputeRange(ctx context.Context, start, end time.Time) (err error) {
|
||||
start = start.UTC().Truncate(time.Minute)
|
||||
end = end.UTC().Truncate(time.Minute)
|
||||
now := time.Now().UTC().Truncate(time.Minute)
|
||||
// Clamp to longest rollup TTL so backfill does not scan beyond product history.
|
||||
maxCutoff := channelMonitorV2RetentionCutoff(now, channelMonitorV2MaxRetention())
|
||||
if start.Before(maxCutoff) {
|
||||
start = maxCutoff
|
||||
}
|
||||
if !start.Before(end) {
|
||||
return nil
|
||||
}
|
||||
tx, err := r.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
if err != nil {
|
||||
_ = tx.Rollback()
|
||||
}
|
||||
}()
|
||||
|
||||
// Idempotent window rewrite: drop existing facts/rollups in [start,end) then re-insert.
|
||||
for _, table := range []string{
|
||||
"channel_monitor_v2_latency_histograms_rollup",
|
||||
"channel_monitor_v2_error_metrics_rollup",
|
||||
"channel_monitor_v2_user_metrics_rollup",
|
||||
"channel_monitor_v2_metrics_rollup",
|
||||
"channel_monitor_v2_latency_histograms_1m",
|
||||
"channel_monitor_v2_error_metrics_1m",
|
||||
"channel_monitor_v2_user_metrics_1m",
|
||||
"channel_monitor_v2_metrics_1m",
|
||||
} {
|
||||
if _, err = tx.ExecContext(ctx, fmt.Sprintf("DELETE FROM %s WHERE bucket_start >= $1 AND bucket_start < $2", table), start, end); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if _, err = tx.ExecContext(ctx, fmt.Sprintf(channelMonitorV2UsageMetricsSQL, channelMonitorV2PlatformSQL, channelMonitorV2ModelSQL), start, end); err != nil {
|
||||
return fmt.Errorf("aggregate channel monitor v2 usage: %w", err)
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, fmt.Sprintf(channelMonitorV2UserMetricsSQL, channelMonitorV2PlatformSQL, channelMonitorV2ModelSQL), start, end); err != nil {
|
||||
return fmt.Errorf("aggregate channel monitor v2 users: %w", err)
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, fmt.Sprintf(channelMonitorV2HistogramSQL, channelMonitorV2PlatformSQL, channelMonitorV2ModelSQL, channelMonitorV2HistogramBoundSQL("latency.value_ms")), start, end); err != nil {
|
||||
return fmt.Errorf("aggregate channel monitor v2 histograms: %w", err)
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, channelMonitorV2ErrorAggregationSQL, start, end); err != nil {
|
||||
return fmt.Errorf("aggregate channel monitor v2 errors: %w", err)
|
||||
}
|
||||
if err = r.recomputeFixedRollups(ctx, tx, start, end); err != nil {
|
||||
return err
|
||||
}
|
||||
// Drop rows past per-tier TTL (1m short, coarse rollups long). Safe after rollup
|
||||
// so a backfill chunk can build 1d rollups from temporary 1m rows then discard 1m.
|
||||
if err = r.pruneChannelMonitorV2Retention(ctx, tx, now); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, channelMonitorV2WatermarkSQL, start, end); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
const channelMonitorV2UsageMetricsSQL = `
|
||||
INSERT INTO channel_monitor_v2_metrics_1m (
|
||||
bucket_start, platform, group_id, model, success_requests,
|
||||
input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens,
|
||||
ttft_sum_ms, ttft_count, duration_sum_ms, duration_count, computed_at
|
||||
)
|
||||
SELECT date_trunc('minute', ul.created_at), %s, COALESCE(ul.group_id, 0), %s,
|
||||
COUNT(DISTINCT COALESCE(NULLIF(ul.request_id, ''), 'usage:' || ul.id::text))
|
||||
FILTER (WHERE COALESCE(ul.request_type, 0) NOT IN (4, 6)),
|
||||
SUM(COALESCE(ul.input_tokens, 0)), SUM(COALESCE(ul.output_tokens, 0)),
|
||||
SUM(COALESCE(ul.cache_creation_tokens, 0)), SUM(COALESCE(ul.cache_read_tokens, 0)),
|
||||
COALESCE(SUM(ul.first_token_ms) FILTER (WHERE ul.first_token_ms IS NOT NULL), 0), COUNT(ul.first_token_ms),
|
||||
COALESCE(SUM(ul.duration_ms) FILTER (WHERE ul.duration_ms IS NOT NULL), 0), COUNT(ul.duration_ms), NOW()
|
||||
FROM usage_logs ul
|
||||
LEFT JOIN groups g ON g.id = ul.group_id
|
||||
LEFT JOIN accounts a ON a.id = ul.account_id
|
||||
WHERE ul.created_at >= $1 AND ul.created_at < $2
|
||||
GROUP BY 1, 2, 3, 4`
|
||||
|
||||
const channelMonitorV2UserMetricsSQL = `
|
||||
INSERT INTO channel_monitor_v2_user_metrics_1m (
|
||||
bucket_start, platform, group_id, model, user_id, success_requests,
|
||||
input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens,
|
||||
ttft_sum_ms, ttft_count, duration_sum_ms, duration_count, computed_at
|
||||
)
|
||||
SELECT date_trunc('minute', ul.created_at), %s, COALESCE(ul.group_id, 0), %s, ul.user_id,
|
||||
COUNT(DISTINCT COALESCE(NULLIF(ul.request_id, ''), 'usage:' || ul.id::text))
|
||||
FILTER (WHERE COALESCE(ul.request_type, 0) NOT IN (4, 6)),
|
||||
SUM(COALESCE(ul.input_tokens, 0)), SUM(COALESCE(ul.output_tokens, 0)),
|
||||
SUM(COALESCE(ul.cache_creation_tokens, 0)), SUM(COALESCE(ul.cache_read_tokens, 0)),
|
||||
COALESCE(SUM(ul.first_token_ms) FILTER (WHERE ul.first_token_ms IS NOT NULL), 0), COUNT(ul.first_token_ms),
|
||||
COALESCE(SUM(ul.duration_ms) FILTER (WHERE ul.duration_ms IS NOT NULL), 0), COUNT(ul.duration_ms), NOW()
|
||||
FROM usage_logs ul
|
||||
LEFT JOIN groups g ON g.id = ul.group_id
|
||||
LEFT JOIN accounts a ON a.id = ul.account_id
|
||||
WHERE ul.created_at >= $1 AND ul.created_at < $2 AND ul.user_id IS NOT NULL
|
||||
GROUP BY 1, 2, 3, 4, 5`
|
||||
|
||||
const channelMonitorV2HistogramSQL = `
|
||||
INSERT INTO channel_monitor_v2_latency_histograms_1m (
|
||||
bucket_start, platform, group_id, model, user_id, metric, upper_bound_ms, sample_count
|
||||
)
|
||||
SELECT date_trunc('minute', ul.created_at), %s, COALESCE(ul.group_id, 0), %s,
|
||||
audience.user_id, latency.metric, %s, COUNT(*)
|
||||
FROM usage_logs ul
|
||||
LEFT JOIN groups g ON g.id = ul.group_id
|
||||
LEFT JOIN accounts a ON a.id = ul.account_id
|
||||
CROSS JOIN LATERAL (VALUES (0::bigint), (ul.user_id)) audience(user_id)
|
||||
CROSS JOIN LATERAL (VALUES ('ttft'::text, ul.first_token_ms), ('duration'::text, ul.duration_ms)) latency(metric, value_ms)
|
||||
WHERE ul.created_at >= $1 AND ul.created_at < $2
|
||||
AND audience.user_id IS NOT NULL AND latency.value_ms IS NOT NULL AND latency.value_ms >= 0
|
||||
GROUP BY 1, 2, 3, 4, 5, 6, 7`
|
||||
|
||||
func channelMonitorV2HistogramBoundSQL(column string) string {
|
||||
return `CASE
|
||||
WHEN ` + column + ` <= 50 THEN 50 WHEN ` + column + ` <= 100 THEN 100
|
||||
WHEN ` + column + ` <= 250 THEN 250 WHEN ` + column + ` <= 500 THEN 500
|
||||
WHEN ` + column + ` <= 1000 THEN 1000 WHEN ` + column + ` <= 2000 THEN 2000
|
||||
WHEN ` + column + ` <= 3000 THEN 3000 WHEN ` + column + ` <= 5000 THEN 5000
|
||||
WHEN ` + column + ` <= 8000 THEN 8000 WHEN ` + column + ` <= 10000 THEN 10000
|
||||
WHEN ` + column + ` <= 15000 THEN 15000 WHEN ` + column + ` <= 30000 THEN 30000
|
||||
WHEN ` + column + ` <= 60000 THEN 60000 WHEN ` + column + ` <= 120000 THEN 120000
|
||||
WHEN ` + column + ` <= 300000 THEN 300000 WHEN ` + column + ` <= 600000 THEN 600000
|
||||
ELSE 2147483647 END`
|
||||
}
|
||||
|
||||
const channelMonitorV2ErrorAggregationSQL = `
|
||||
WITH dedup AS (
|
||||
SELECT DISTINCT ON (COALESCE(NULLIF(request_id, ''), 'error:' || id::text))
|
||||
date_trunc('minute', created_at) AS bucket_start,
|
||||
lower(COALESCE(NULLIF(TRIM(platform), ''), 'unknown')) AS platform,
|
||||
COALESCE(group_id, 0) AS group_id,
|
||||
COALESCE(NULLIF(TRIM(requested_model), ''), NULLIF(TRIM(model), ''), 'unknown') AS model,
|
||||
user_id, error_type, error_owner, COALESCE(status_code, 0) AS status_code,
|
||||
COALESCE(upstream_status_code, 0) AS upstream_status_code,
|
||||
lower(CONCAT_WS(' ', error_type, error_source, error_message, upstream_error_message, upstream_error_detail, error_body)) AS text,
|
||||
(CASE WHEN jsonb_typeof(upstream_errors) = 'array' THEN jsonb_array_length(upstream_errors) > 0 ELSE FALSE END
|
||||
OR error_owner = 'provider' OR upstream_status_code IS NOT NULL) AS upstream_affected,
|
||||
CASE WHEN jsonb_typeof(upstream_errors) = 'array' THEN jsonb_array_length(upstream_errors) ELSE 0 END AS upstream_attempts
|
||||
FROM ops_error_logs current_error
|
||||
WHERE current_error.created_at >= $1 AND current_error.created_at < $2 AND NOT current_error.is_count_tokens
|
||||
AND (COALESCE(current_error.status_code, 0) >= 400 OR current_error.error_type = 'cyber_policy')
|
||||
AND (NULLIF(current_error.request_id, '') IS NULL OR NOT EXISTS (
|
||||
SELECT 1 FROM ops_error_logs newer
|
||||
WHERE newer.request_id = current_error.request_id
|
||||
AND NOT newer.is_count_tokens
|
||||
AND (COALESCE(newer.status_code, 0) >= 400 OR newer.error_type = 'cyber_policy')
|
||||
AND newer.created_at < $2
|
||||
AND (newer.created_at, newer.id) > (current_error.created_at, current_error.id)
|
||||
))
|
||||
ORDER BY COALESCE(NULLIF(request_id, ''), 'error:' || id::text), created_at DESC, id DESC
|
||||
), classified AS (
|
||||
SELECT *, CASE
|
||||
-- Keep in lockstep with service.ClassifyChannelMonitorV2Error needles.
|
||||
WHEN error_type = 'cyber_policy' OR text LIKE ANY(ARRAY['%content policy%','%content_policy%','%safety policy%','%moderation%','%blocked keyword%']) THEN 'content_policy'
|
||||
WHEN status_code = 401 OR upstream_status_code = 401 OR text LIKE ANY(ARRAY['%unauthorized%','%invalid api key%','%invalid_api_key%','%authentication%','%api_key_disabled%']) THEN 'authentication'
|
||||
WHEN text LIKE ANY(ARRAY['%context window%','%context length%','%maximum prompt length%','%too many tokens%','%max_tokens%']) THEN 'context_limit'
|
||||
WHEN text LIKE ANY(ARRAY['%failed to deserialize%','%missing required parameter%','%invalid request%','%invalid_request%','%tool_choice%']) THEN 'invalid_request'
|
||||
WHEN text LIKE ANY(ARRAY['%does not support the requested model%','%not supported by any configured account%','%model not supported%','%unsupported model%']) THEN 'model_unsupported'
|
||||
WHEN text LIKE ANY(ARRAY['%group not allowed%','%group_not_allowed%','%group access%']) THEN 'group_access'
|
||||
WHEN text LIKE ANY(ARRAY['%run out of credits%','%insufficient balance%','%insufficient quota%','%subscription%','%quota exceeded%','%billing hard limit%']) THEN 'quota_or_balance'
|
||||
WHEN text LIKE ANY(ARRAY['%no available accounts%','%no healthy account%','%no healthy upstream account%','%failover budget exhausted%','%account pool%']) THEN 'account_pool_unavailable'
|
||||
WHEN status_code = 429 OR upstream_status_code = 429 OR text LIKE ANY(ARRAY['%rate limit%','%rate_limit%','%high demand%','%overloaded%','%concurrency limit%','%capacity%']) THEN 'rate_or_capacity'
|
||||
WHEN status_code IN (408,504) OR text LIKE ANY(ARRAY['%timeout%','%deadline exceeded%','%error code: 524%','%gateway time-out%','%gateway timeout%']) THEN 'timeout'
|
||||
WHEN text LIKE ANY(ARRAY['%transport%','%stream_read_error%','%connection reset%','%connection refused%','%tls%','%http2%','%missing terminal event%','%unexpected eof%']) THEN 'transport_or_stream'
|
||||
WHEN status_code = 403 OR upstream_status_code = 403 THEN 'upstream_forbidden'
|
||||
WHEN status_code = 404 OR upstream_status_code = 404 THEN 'not_found'
|
||||
WHEN status_code = 499 OR text LIKE ANY(ARRAY['%client cancelled%','%client canceled%','%context canceled%']) THEN 'client_cancelled'
|
||||
WHEN upstream_status_code >= 500 OR (error_owner = 'provider' AND status_code >= 500) THEN 'upstream_5xx'
|
||||
WHEN status_code >= 500 OR error_type = 'internal' OR error_owner = 'system' THEN 'internal'
|
||||
ELSE 'other' END AS category
|
||||
FROM dedup
|
||||
), metric_rows AS (
|
||||
INSERT INTO channel_monitor_v2_metrics_1m (bucket_start, platform, group_id, model, error_requests, upstream_affected_requests, upstream_attempt_count, computed_at)
|
||||
SELECT bucket_start, platform, group_id, model, COUNT(*), COUNT(*) FILTER (WHERE upstream_affected), SUM(upstream_attempts), NOW()
|
||||
FROM classified GROUP BY 1,2,3,4
|
||||
ON CONFLICT (bucket_start, platform, group_id, model) DO UPDATE SET
|
||||
error_requests = EXCLUDED.error_requests, upstream_affected_requests = EXCLUDED.upstream_affected_requests,
|
||||
upstream_attempt_count = EXCLUDED.upstream_attempt_count, computed_at = NOW()
|
||||
), user_rows AS (
|
||||
INSERT INTO channel_monitor_v2_user_metrics_1m (bucket_start, platform, group_id, model, user_id, error_requests, computed_at)
|
||||
SELECT bucket_start, platform, group_id, model, user_id, COUNT(*), NOW()
|
||||
FROM classified WHERE user_id IS NOT NULL GROUP BY 1,2,3,4,5
|
||||
ON CONFLICT (bucket_start, platform, group_id, model, user_id) DO UPDATE SET error_requests = EXCLUDED.error_requests, computed_at = NOW()
|
||||
)
|
||||
INSERT INTO channel_monitor_v2_error_metrics_1m (bucket_start, platform, group_id, model, error_category, taxonomy_version, error_requests)
|
||||
SELECT bucket_start, platform, group_id, model, category, 1, COUNT(*) FROM classified GROUP BY 1,2,3,4,5
|
||||
ON CONFLICT (bucket_start, platform, group_id, model, error_category, taxonomy_version)
|
||||
DO UPDATE SET error_requests = EXCLUDED.error_requests`
|
||||
|
||||
// Floor matches channelMonitorV2RetentionMax (90d). Keep the INTERVAL literal in
|
||||
// sync when changing channelMonitorV2RetentionRollup1d.
|
||||
const channelMonitorV2WatermarkSQL = `
|
||||
INSERT INTO channel_monitor_v2_watermarks (id, usage_coverage_start, error_coverage_start, data_through, last_successful_at, backfill_cursor, updated_at)
|
||||
VALUES (
|
||||
1,
|
||||
GREATEST($1, COALESCE((SELECT created_at FROM usage_logs ORDER BY created_at ASC LIMIT 1), $1)),
|
||||
GREATEST($1, COALESCE((SELECT created_at FROM ops_error_logs ORDER BY created_at ASC LIMIT 1), $1)),
|
||||
$2, NOW(), $1, NOW()
|
||||
)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
usage_coverage_start = GREATEST(
|
||||
date_trunc('minute', NOW()) - INTERVAL '90 days',
|
||||
LEAST(COALESCE(channel_monitor_v2_watermarks.usage_coverage_start, EXCLUDED.usage_coverage_start), EXCLUDED.usage_coverage_start)
|
||||
),
|
||||
error_coverage_start = GREATEST(
|
||||
date_trunc('minute', NOW()) - INTERVAL '90 days',
|
||||
LEAST(COALESCE(channel_monitor_v2_watermarks.error_coverage_start, EXCLUDED.error_coverage_start), EXCLUDED.error_coverage_start)
|
||||
),
|
||||
data_through = GREATEST(COALESCE(channel_monitor_v2_watermarks.data_through, EXCLUDED.data_through), EXCLUDED.data_through),
|
||||
last_successful_at = NOW(),
|
||||
backfill_cursor = LEAST(COALESCE(channel_monitor_v2_watermarks.backfill_cursor, EXCLUDED.backfill_cursor), EXCLUDED.backfill_cursor),
|
||||
updated_at = NOW()`
|
||||
|
||||
var channelMonitorV2FixedRollupSeconds = []int{300, 3600, 43200, 86400}
|
||||
|
||||
func (r *channelMonitorV2Repository) recomputeFixedRollups(ctx context.Context, tx *sql.Tx, start, end time.Time) error {
|
||||
for _, seconds := range channelMonitorV2FixedRollupSeconds {
|
||||
interval := fmt.Sprintf("%d seconds", seconds)
|
||||
for _, table := range []string{
|
||||
"channel_monitor_v2_latency_histograms_rollup",
|
||||
"channel_monitor_v2_error_metrics_rollup",
|
||||
"channel_monitor_v2_user_metrics_rollup",
|
||||
"channel_monitor_v2_metrics_rollup",
|
||||
} {
|
||||
if _, err := tx.ExecContext(ctx, fmt.Sprintf(channelMonitorV2FixedRollupDeleteSQL, table), interval, seconds, start, end); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, channelMonitorV2MetricsRollupSQL, interval, seconds, start, end); err != nil {
|
||||
return fmt.Errorf("roll up channel monitor v2 metrics %ds: %w", seconds, err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, channelMonitorV2UserMetricsRollupSQL, interval, seconds, start, end); err != nil {
|
||||
return fmt.Errorf("roll up channel monitor v2 user metrics %ds: %w", seconds, err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, channelMonitorV2HistogramRollupSQL, interval, seconds, start, end); err != nil {
|
||||
return fmt.Errorf("roll up channel monitor v2 histograms %ds: %w", seconds, err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, channelMonitorV2ErrorRollupSQL, interval, seconds, start, end); err != nil {
|
||||
return fmt.Errorf("roll up channel monitor v2 errors %ds: %w", seconds, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
const channelMonitorV2FixedRollupBoundsSQL = `
|
||||
WITH bounds AS (
|
||||
SELECT
|
||||
date_bin($1::interval, $3::timestamptz, TIMESTAMPTZ '1970-01-01') AS start_at,
|
||||
date_bin($1::interval, $4::timestamptz - INTERVAL '1 microsecond', TIMESTAMPTZ '1970-01-01') + $1::interval AS end_at
|
||||
)`
|
||||
|
||||
const channelMonitorV2FixedRollupDeleteSQL = channelMonitorV2FixedRollupBoundsSQL + `
|
||||
DELETE FROM %s
|
||||
USING bounds
|
||||
WHERE bucket_seconds = $2::integer
|
||||
AND bucket_start >= bounds.start_at
|
||||
AND bucket_start < bounds.end_at`
|
||||
|
||||
const channelMonitorV2MetricsRollupSQL = `
|
||||
INSERT INTO channel_monitor_v2_metrics_rollup (
|
||||
bucket_start, bucket_seconds, platform, group_id, model, success_requests, error_requests,
|
||||
upstream_affected_requests, upstream_attempt_count, input_tokens, output_tokens,
|
||||
cache_creation_tokens, cache_read_tokens, ttft_sum_ms, ttft_count, duration_sum_ms,
|
||||
duration_count, computed_at
|
||||
)
|
||||
` + channelMonitorV2FixedRollupBoundsSQL + `
|
||||
SELECT date_bin($1::interval, m.bucket_start, TIMESTAMPTZ '1970-01-01'), $2::integer,
|
||||
platform, group_id, model, SUM(success_requests), SUM(error_requests),
|
||||
SUM(upstream_affected_requests), SUM(upstream_attempt_count), SUM(input_tokens),
|
||||
SUM(output_tokens), SUM(cache_creation_tokens), SUM(cache_read_tokens),
|
||||
SUM(ttft_sum_ms), SUM(ttft_count), SUM(duration_sum_ms), SUM(duration_count), NOW()
|
||||
FROM channel_monitor_v2_metrics_1m m, bounds
|
||||
WHERE m.bucket_start >= bounds.start_at AND m.bucket_start < bounds.end_at
|
||||
GROUP BY 1, 2, 3, 4, 5`
|
||||
|
||||
const channelMonitorV2UserMetricsRollupSQL = `
|
||||
INSERT INTO channel_monitor_v2_user_metrics_rollup (
|
||||
bucket_start, bucket_seconds, platform, group_id, model, user_id, success_requests,
|
||||
error_requests, input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens,
|
||||
ttft_sum_ms, ttft_count, duration_sum_ms, duration_count, computed_at
|
||||
)
|
||||
` + channelMonitorV2FixedRollupBoundsSQL + `
|
||||
SELECT date_bin($1::interval, m.bucket_start, TIMESTAMPTZ '1970-01-01'), $2::integer,
|
||||
platform, group_id, model, user_id, SUM(success_requests), SUM(error_requests),
|
||||
SUM(input_tokens), SUM(output_tokens), SUM(cache_creation_tokens), SUM(cache_read_tokens),
|
||||
SUM(ttft_sum_ms), SUM(ttft_count), SUM(duration_sum_ms), SUM(duration_count), NOW()
|
||||
FROM channel_monitor_v2_user_metrics_1m m, bounds
|
||||
WHERE m.bucket_start >= bounds.start_at AND m.bucket_start < bounds.end_at
|
||||
GROUP BY 1, 2, 3, 4, 5, 6`
|
||||
|
||||
const channelMonitorV2HistogramRollupSQL = `
|
||||
INSERT INTO channel_monitor_v2_latency_histograms_rollup (
|
||||
bucket_start, bucket_seconds, platform, group_id, model, user_id, metric, upper_bound_ms, sample_count
|
||||
)
|
||||
` + channelMonitorV2FixedRollupBoundsSQL + `
|
||||
SELECT date_bin($1::interval, h.bucket_start, TIMESTAMPTZ '1970-01-01'), $2::integer,
|
||||
platform, group_id, model, user_id, metric, upper_bound_ms, SUM(sample_count)
|
||||
FROM channel_monitor_v2_latency_histograms_1m h, bounds
|
||||
WHERE h.bucket_start >= bounds.start_at AND h.bucket_start < bounds.end_at
|
||||
GROUP BY 1, 2, 3, 4, 5, 6, 7, 8`
|
||||
|
||||
const channelMonitorV2ErrorRollupSQL = `
|
||||
INSERT INTO channel_monitor_v2_error_metrics_rollup (
|
||||
bucket_start, bucket_seconds, platform, group_id, model, error_category, taxonomy_version, error_requests
|
||||
)
|
||||
` + channelMonitorV2FixedRollupBoundsSQL + `
|
||||
SELECT date_bin($1::interval, e.bucket_start, TIMESTAMPTZ '1970-01-01'), $2::integer,
|
||||
platform, group_id, model, error_category, taxonomy_version, SUM(error_requests)
|
||||
FROM channel_monitor_v2_error_metrics_1m e, bounds
|
||||
WHERE e.bucket_start >= bounds.start_at AND e.bucket_start < bounds.end_at
|
||||
GROUP BY 1, 2, 3, 4, 5, 6, 7`
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,265 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestChannelMonitorV2DisplayModelIsPlatformScoped(t *testing.T) {
|
||||
cfg := service.ChannelMonitorV2Config{Platforms: []service.ChannelMonitorV2PlatformConfig{
|
||||
{Platform: "openai", Enabled: true, Models: []string{"shared", "gpt-5"}},
|
||||
{Platform: "grok", Enabled: true, Models: []string{"grok-4"}},
|
||||
// Empty models list must NOT collapse everything into __other__.
|
||||
{Platform: "anthropic", Enabled: true, Models: []string{}},
|
||||
}}
|
||||
require.Equal(t, "shared", channelMonitorV2DisplayModel(cfg, "openai", "shared"))
|
||||
require.Equal(t, service.ChannelMonitorV2OtherModel, channelMonitorV2DisplayModel(cfg, "grok", "shared"))
|
||||
require.Equal(t, "claude-sonnet-4", channelMonitorV2DisplayModel(cfg, "anthropic", "claude-sonnet-4"))
|
||||
// Unconfigured platform still surfaces the real model name.
|
||||
require.Equal(t, "gemini-2.5-pro", channelMonitorV2DisplayModel(cfg, "gemini", "gemini-2.5-pro"))
|
||||
require.True(t, channelMonitorV2ModelSelected(service.ChannelMonitorV2Filter{Models: []string{service.ChannelMonitorV2OtherModel}}, cfg, "grok", "shared"))
|
||||
}
|
||||
|
||||
func TestChannelMonitorV2MatrixDimensionKey(t *testing.T) {
|
||||
cfg := service.ChannelMonitorV2Config{Platforms: []service.ChannelMonitorV2PlatformConfig{{Platform: "openai", Enabled: true, Models: []string{"gpt-5"}}}}
|
||||
key := channelMonitorV2MatrixDimensionKey(service.ChannelMonitorV2GroupByPlatformGroupModel, cfg, "openai", 7, "gpt-5")
|
||||
require.Equal(t, channelMonitorV2MatrixKey{platform: "openai", groupID: 7, model: "gpt-5"}, key)
|
||||
key = channelMonitorV2MatrixDimensionKey(service.ChannelMonitorV2GroupByPlatformModel, cfg, "openai", 7, "unlisted")
|
||||
require.Equal(t, channelMonitorV2MatrixKey{platform: "openai", model: service.ChannelMonitorV2OtherModel}, key)
|
||||
key = channelMonitorV2MatrixDimensionKey(service.ChannelMonitorV2GroupByPlatform, cfg, "openai", 7, "gpt-5")
|
||||
require.Equal(t, channelMonitorV2MatrixKey{platform: "openai"}, key)
|
||||
}
|
||||
|
||||
func TestChannelMonitorV2HistogramPercentilesAreMergedFromCounts(t *testing.T) {
|
||||
// 100 samples: 50@100, 40@500, 10@1000
|
||||
// target = int64(total*p + 0.999999) truncates: p50→50, p90→90, p95→95
|
||||
// cumulative hits: p50@100, p90@500 (50+40), p95@1000
|
||||
histogram := map[int64]int64{100: 50, 500: 40, 1000: 10}
|
||||
require.Equal(t, int64(100), *histPercentile(histogram, .5))
|
||||
require.Equal(t, int64(500), *histPercentile(histogram, .9))
|
||||
require.Equal(t, int64(1000), *histPercentile(histogram, .95))
|
||||
require.Nil(t, histPercentile(nil, .95))
|
||||
// latencyMetric exposes avg + p50 + p90 + p95
|
||||
lat := latencyMetric(1000, 10, histogram)
|
||||
require.NotNil(t, lat.AvgMs)
|
||||
require.NotNil(t, lat.P50Ms)
|
||||
require.NotNil(t, lat.P90Ms)
|
||||
require.NotNil(t, lat.P95Ms)
|
||||
require.Equal(t, int64(100), *lat.P50Ms)
|
||||
require.Equal(t, int64(500), *lat.P90Ms)
|
||||
require.Equal(t, int64(1000), *lat.P95Ms)
|
||||
}
|
||||
|
||||
func TestChannelMonitorV2MetricIncludesSuccessRate(t *testing.T) {
|
||||
acc := newMetricAccumulator()
|
||||
acc.success, acc.errors = 80, 20
|
||||
metric := acc.metric(1, false)
|
||||
require.Equal(t, int64(100), metric.RequestCount)
|
||||
require.InDelta(t, 0.8, metric.SuccessRate, 0.0001)
|
||||
require.InDelta(t, 0.2, metric.ErrorRate, 0.0001)
|
||||
require.Nil(t, metric.UpstreamAffectedRequests)
|
||||
|
||||
adminMetric := acc.metric(1, true)
|
||||
require.NotNil(t, adminMetric.UpstreamAffectedRequests)
|
||||
}
|
||||
|
||||
func TestChannelMonitorV2WhereUsesConfiguredScopeAndEmptyFilterMeansAllConfigured(t *testing.T) {
|
||||
filter := service.ChannelMonitorV2Filter{Start: time.Unix(1, 0), End: time.Unix(2, 0)}
|
||||
cfg := service.ChannelMonitorV2Config{
|
||||
Platforms: []service.ChannelMonitorV2PlatformConfig{{Platform: "openai", Enabled: true}, {Platform: "grok", Enabled: false}},
|
||||
GroupIDs: []int64{3, 4},
|
||||
}
|
||||
where, args := channelMonitorV2Where(filter, cfg, "m")
|
||||
require.Contains(t, where, "m.platform = ANY($3)")
|
||||
require.Contains(t, where, "m.group_id = ANY($4)")
|
||||
require.Len(t, args, 4)
|
||||
}
|
||||
|
||||
func TestChannelMonitorV2WhereRejectsGroupFilterOutsideConfiguredScope(t *testing.T) {
|
||||
filter := service.ChannelMonitorV2Filter{
|
||||
Start: time.Unix(1, 0), End: time.Unix(2, 0), GroupIDs: []int64{9},
|
||||
}
|
||||
cfg := service.ChannelMonitorV2Config{
|
||||
Platforms: []service.ChannelMonitorV2PlatformConfig{{Platform: "openai", Enabled: true}},
|
||||
GroupIDs: []int64{3, 4},
|
||||
}
|
||||
where, args := channelMonitorV2Where(filter, cfg, "m")
|
||||
require.Contains(t, where, "FALSE")
|
||||
require.NotContains(t, where, "m.group_id = ANY")
|
||||
require.Len(t, args, 3)
|
||||
}
|
||||
|
||||
func TestChannelMonitorV2ErrorAggregationCountsFinalUserErrorsOnly(t *testing.T) {
|
||||
query := strings.ToLower(channelMonitorV2ErrorAggregationSQL)
|
||||
require.Contains(t, query, "not current_error.is_count_tokens")
|
||||
require.Contains(t, query, "error_type = 'cyber_policy'")
|
||||
require.Contains(t, query, "distinct on")
|
||||
require.Contains(t, query, "not exists")
|
||||
require.Contains(t, query, "upstream_affected_requests")
|
||||
require.Contains(t, query, "jsonb_array_length(upstream_errors) > 0")
|
||||
}
|
||||
|
||||
func TestChannelMonitorV2UsageSuccessExcludesCyberBillingRows(t *testing.T) {
|
||||
for _, query := range []string{channelMonitorV2UsageMetricsSQL, channelMonitorV2UserMetricsSQL} {
|
||||
require.Contains(t, query, "COALESCE(ul.request_type, 0) NOT IN (4, 6)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelMonitorV2RatesUseCoveredWindow(t *testing.T) {
|
||||
start := time.Date(2026, 8, 1, 0, 0, 0, 0, time.UTC)
|
||||
filter := service.ChannelMonitorV2Filter{Start: start, End: start.Add(24 * time.Hour)}
|
||||
coverage := service.ChannelMonitorV2Coverage{CoverageStart: start.Add(6 * time.Hour), DataThrough: start.Add(18 * time.Hour)}
|
||||
require.Equal(t, 12*60.0, channelMonitorV2CoveredMinutes(filter, coverage))
|
||||
effective := channelMonitorV2CommonCoverageFilter(filter, coverage)
|
||||
require.Equal(t, coverage.CoverageStart, effective.Start)
|
||||
require.Equal(t, coverage.DataThrough, effective.End)
|
||||
}
|
||||
|
||||
func TestChannelMonitorV2TierRetentionPolicy(t *testing.T) {
|
||||
require.Equal(t, 3*24*time.Hour, channelMonitorV2RetentionUser1m)
|
||||
require.Equal(t, 7*24*time.Hour, channelMonitorV2RetentionMetrics1m)
|
||||
require.Equal(t, 7*24*time.Hour, channelMonitorV2RetentionError1m)
|
||||
require.Equal(t, 7*24*time.Hour, channelMonitorV2RetentionHistogram1m)
|
||||
require.Equal(t, 7*24*time.Hour, channelMonitorV2RetentionRollup5m)
|
||||
require.Equal(t, 30*24*time.Hour, channelMonitorV2RetentionRollup1h)
|
||||
require.Equal(t, 45*24*time.Hour, channelMonitorV2RetentionRollup12h)
|
||||
require.Equal(t, 90*24*time.Hour, channelMonitorV2RetentionRollup1d)
|
||||
require.Equal(t, channelMonitorV2RetentionRollup1d, channelMonitorV2MaxRetention())
|
||||
require.Contains(t, channelMonitorV2WatermarkSQL, "INTERVAL '90 days'")
|
||||
|
||||
// Every fixed rollup second must appear with a retention rule.
|
||||
wantSeconds := map[int]time.Duration{
|
||||
300: channelMonitorV2RetentionRollup5m,
|
||||
3600: channelMonitorV2RetentionRollup1h,
|
||||
43200: channelMonitorV2RetentionRollup12h,
|
||||
86400: channelMonitorV2RetentionRollup1d,
|
||||
}
|
||||
seen := map[int]time.Duration{}
|
||||
for _, rule := range channelMonitorV2RetentionRules {
|
||||
if rule.bucketSeconds == 0 {
|
||||
require.True(t, rule.retention > 0)
|
||||
continue
|
||||
}
|
||||
if prev, ok := seen[rule.bucketSeconds]; ok {
|
||||
require.Equal(t, prev, rule.retention)
|
||||
}
|
||||
seen[rule.bucketSeconds] = rule.retention
|
||||
}
|
||||
for seconds, want := range wantSeconds {
|
||||
got, ok := seen[seconds]
|
||||
require.Truef(t, ok, "missing retention rule for bucket_seconds=%d", seconds)
|
||||
require.Equal(t, want, got)
|
||||
}
|
||||
|
||||
now := time.Date(2026, 8, 7, 12, 0, 0, 0, time.UTC)
|
||||
require.Equal(t, now.Add(-7*24*time.Hour), channelMonitorV2RetentionCutoff(now, channelMonitorV2RetentionMetrics1m))
|
||||
require.Equal(t, now.Add(-90*24*time.Hour), channelMonitorV2RetentionCutoff(now, channelMonitorV2MaxRetention()))
|
||||
}
|
||||
|
||||
// Needles present in service.ClassifyChannelMonitorV2Error must appear in the
|
||||
// aggregation SQL CASE so rollup categories match drilldown classification.
|
||||
func TestChannelMonitorV2SQLTaxonomyContainsGoNeedles(t *testing.T) {
|
||||
sql := channelMonitorV2ErrorAggregationSQL
|
||||
needles := []string{
|
||||
"blocked keyword",
|
||||
"invalid_api_key",
|
||||
"max_tokens",
|
||||
"invalid_request",
|
||||
"model not supported",
|
||||
"billing hard limit",
|
||||
"no healthy upstream account",
|
||||
"rate_limit",
|
||||
"gateway timeout",
|
||||
"connection refused",
|
||||
"unexpected eof",
|
||||
}
|
||||
for _, needle := range needles {
|
||||
require.Containsf(t, strings.ToLower(sql), strings.ToLower(needle), "SQL taxonomy missing Go needle %q", needle)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyIgnoredErrorsAdjustsRatesKeepsAbsoluteVolume(t *testing.T) {
|
||||
m := service.ChannelMonitorV2Metric{
|
||||
RequestCount: 100,
|
||||
ErrorRequests: 20,
|
||||
ErrorRate: 0.20,
|
||||
SuccessRate: 0.80,
|
||||
}
|
||||
// Success absolute still 80 → success rate stays 0.80 even after ignoring 5 errors.
|
||||
m.SuccessRequests = 80
|
||||
applyIgnoredErrors(&m, 5)
|
||||
require.Equal(t, int64(100), m.RequestCount)
|
||||
require.Equal(t, int64(20), m.ErrorRequests)
|
||||
require.InDelta(t, 0.15, m.ErrorRate, 0.0001)
|
||||
require.InDelta(t, 0.80, m.SuccessRate, 0.0001)
|
||||
|
||||
// Clamp ignored > errors: scored error_rate → 0; success stays true ratio.
|
||||
m2 := service.ChannelMonitorV2Metric{RequestCount: 10, ErrorRequests: 2, SuccessRequests: 8, ErrorRate: 0.2, SuccessRate: 0.8}
|
||||
applyIgnoredErrors(&m2, 99)
|
||||
require.InDelta(t, 0.0, m2.ErrorRate, 0.0001)
|
||||
require.InDelta(t, 0.8, m2.SuccessRate, 0.0001)
|
||||
|
||||
// No-op when ignored is zero
|
||||
m3 := service.ChannelMonitorV2Metric{RequestCount: 10, ErrorRequests: 2, SuccessRequests: 8, ErrorRate: 0.2, SuccessRate: 0.8}
|
||||
applyIgnoredErrors(&m3, 0)
|
||||
require.InDelta(t, 0.2, m3.ErrorRate, 0.0001)
|
||||
require.InDelta(t, 0.8, m3.SuccessRate, 0.0001)
|
||||
}
|
||||
|
||||
func TestRedactChannelMonitorV2MetricZerosVolume(t *testing.T) {
|
||||
// Service helper is in service package; covered there. Keep a smoke note that
|
||||
// rates survive a manual zeroing of volume fields used by the UI contract.
|
||||
m := service.ChannelMonitorV2Metric{
|
||||
RequestCount: 100, ErrorRequests: 10, SuccessRequests: 90,
|
||||
TokenCount: 1000, RPM: 5, TPM: 50, ErrorRate: 0.1, SuccessRate: 0.9, CacheRate: 0.4,
|
||||
}
|
||||
// Mimic redact: zero volume only
|
||||
m.RequestCount, m.ErrorRequests, m.SuccessRequests, m.TokenCount = 0, 0, 0, 0
|
||||
require.Equal(t, 0.1, m.ErrorRate)
|
||||
require.Equal(t, 5.0, m.RPM)
|
||||
}
|
||||
|
||||
func TestChannelMonitorV2CatalogFilterClearsMultiSelectDimensions(t *testing.T) {
|
||||
start := time.Unix(1, 0)
|
||||
end := time.Unix(2, 0)
|
||||
filter := service.ChannelMonitorV2Filter{
|
||||
Start: start, End: end, Bucket: time.Minute,
|
||||
Platforms: []string{"openai"}, GroupIDs: []int64{3}, Models: []string{"gpt-5"},
|
||||
}
|
||||
catalog := channelMonitorV2CatalogFilter(filter)
|
||||
require.Nil(t, catalog.Platforms)
|
||||
require.Nil(t, catalog.GroupIDs)
|
||||
require.Nil(t, catalog.Models)
|
||||
// Time window / coverage-related fields remain.
|
||||
require.Equal(t, start, catalog.Start)
|
||||
require.Equal(t, end, catalog.End)
|
||||
require.Equal(t, time.Minute, catalog.Bucket)
|
||||
|
||||
cfg := service.ChannelMonitorV2Config{
|
||||
Platforms: []service.ChannelMonitorV2PlatformConfig{
|
||||
{Platform: "openai", Enabled: true},
|
||||
{Platform: "grok", Enabled: true},
|
||||
},
|
||||
GroupIDs: []int64{3, 4},
|
||||
}
|
||||
catalogWhere, catalogArgs := channelMonitorV2Where(catalog, cfg, "m")
|
||||
_, metricArgs := channelMonitorV2Where(filter, cfg, "m")
|
||||
|
||||
// Catalog WHERE still applies config scope (enabled platforms + group allow-list).
|
||||
require.Contains(t, catalogWhere, "m.platform = ANY")
|
||||
require.Contains(t, catalogWhere, "m.group_id = ANY")
|
||||
require.Len(t, catalogArgs, 4) // start, end, platforms, groups
|
||||
require.Len(t, metricArgs, 4)
|
||||
|
||||
// Metrics WHERE is narrower once multi-select platforms/groups are applied.
|
||||
require.NotEqual(t, catalogArgs, metricArgs)
|
||||
// Group seeding without multi-select uses full config allow-list.
|
||||
require.Equal(t, []int64{3, 4}, configuredChannelMonitorV2GroupIDs(catalog, cfg))
|
||||
require.Equal(t, []int64{3}, configuredChannelMonitorV2GroupIDs(filter, cfg))
|
||||
}
|
||||
|
||||
|
||||
@@ -157,3 +157,18 @@ var (
|
||||
"CHANNEL_MONITOR_KEY_DECRYPT_FAILED", "api key decryption failed; please re-edit the monitor with a fresh key",
|
||||
)
|
||||
)
|
||||
|
||||
var (
|
||||
ErrChannelMonitorDisabled = infraerrors.Forbidden(
|
||||
"CHANNEL_MONITOR_DISABLED",
|
||||
"channel monitor feature is disabled",
|
||||
)
|
||||
ErrChannelMonitorActiveProbesRetired = infraerrors.Forbidden(
|
||||
"CHANNEL_MONITOR_ACTIVE_PROBES_RETIRED",
|
||||
"channel monitor active probes are retired in v2 mode",
|
||||
)
|
||||
ErrChannelMonitorModeMismatch = infraerrors.Forbidden(
|
||||
"CHANNEL_MONITOR_MODE_MISMATCH",
|
||||
"channel monitor mode does not allow this operation",
|
||||
)
|
||||
)
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
//go:build unit
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type channelMonitorRuntimeStub struct {
|
||||
rt ChannelMonitorRuntime
|
||||
}
|
||||
|
||||
func (s channelMonitorRuntimeStub) GetChannelMonitorRuntime(context.Context) ChannelMonitorRuntime {
|
||||
return s.rt
|
||||
}
|
||||
|
||||
func TestRunCheck_ModeV2NeverProbes(t *testing.T) {
|
||||
svc := NewChannelMonitorService(nil, nil)
|
||||
svc.SetRuntimeReader(channelMonitorRuntimeStub{rt: ChannelMonitorRuntime{
|
||||
Enabled: true,
|
||||
Mode: ChannelMonitorModeV2,
|
||||
}})
|
||||
|
||||
results, err := svc.RunCheck(context.Background(), 1)
|
||||
require.ErrorIs(t, err, ErrChannelMonitorActiveProbesRetired)
|
||||
require.Nil(t, results)
|
||||
}
|
||||
|
||||
func TestRunCheck_DisabledReturnsDisabled(t *testing.T) {
|
||||
svc := NewChannelMonitorService(nil, nil)
|
||||
svc.SetRuntimeReader(channelMonitorRuntimeStub{rt: ChannelMonitorRuntime{
|
||||
Enabled: false,
|
||||
Mode: ChannelMonitorModeV1,
|
||||
}})
|
||||
|
||||
_, err := svc.RunCheck(context.Background(), 1)
|
||||
require.ErrorIs(t, err, ErrChannelMonitorDisabled)
|
||||
}
|
||||
|
||||
func TestRunCheck_NilRuntimeReaderFailsClosedAsV2(t *testing.T) {
|
||||
svc := NewChannelMonitorService(nil, nil)
|
||||
// No SetRuntimeReader → probeRuntime defaults to mode=v2 (retired).
|
||||
_, err := svc.RunCheck(context.Background(), 1)
|
||||
require.ErrorIs(t, err, ErrChannelMonitorActiveProbesRetired)
|
||||
}
|
||||
|
||||
func TestNormalizeChannelMonitorMode(t *testing.T) {
|
||||
require.Equal(t, ChannelMonitorModeV2, normalizeChannelMonitorMode(""))
|
||||
require.Equal(t, ChannelMonitorModeV1, normalizeChannelMonitorMode("v1"))
|
||||
require.Equal(t, ChannelMonitorModeV2, normalizeChannelMonitorMode("v2"))
|
||||
require.Equal(t, ChannelMonitorModeV2, normalizeChannelMonitorMode("invalid"))
|
||||
require.Equal(t, ChannelMonitorModeV1, normalizeChannelMonitorMode(" V1 "))
|
||||
}
|
||||
|
||||
func TestChannelMonitorRuntimeActiveProbesAllowed(t *testing.T) {
|
||||
require.False(t, (ChannelMonitorRuntime{Enabled: false, Mode: ChannelMonitorModeV1}).ActiveProbesAllowed())
|
||||
require.True(t, (ChannelMonitorRuntime{Enabled: true, Mode: ChannelMonitorModeV1}).ActiveProbesAllowed())
|
||||
require.False(t, (ChannelMonitorRuntime{Enabled: true, Mode: ChannelMonitorModeV2}).ActiveProbesAllowed())
|
||||
require.True(t, (ChannelMonitorRuntime{Enabled: true, Mode: ChannelMonitorModeV2}).PassiveAggregationAllowed())
|
||||
}
|
||||
@@ -257,8 +257,11 @@ func (r *ChannelMonitorRunner) runScheduled(ctx context.Context, task *scheduled
|
||||
// fire 提交一次检测到 worker 池。功能开关关闭时跳过本次(不取消任务,
|
||||
// 重新启用时立即恢复);池满或重复在飞时也跳过。
|
||||
func (r *ChannelMonitorRunner) fire(ctx context.Context, task *scheduledMonitor) {
|
||||
if r.settingService != nil && !r.settingService.GetChannelMonitorRuntime(ctx).Enabled {
|
||||
return
|
||||
if r.settingService != nil {
|
||||
rt := r.settingService.GetChannelMonitorRuntime(ctx)
|
||||
if !rt.ActiveProbesAllowed() {
|
||||
return
|
||||
}
|
||||
}
|
||||
if !r.tryAcquireInFlight(task.id) {
|
||||
slog.Debug("channel_monitor: skip already in-flight",
|
||||
|
||||
@@ -61,10 +61,19 @@ type ChannelMonitorRepository interface {
|
||||
UpdateAggregationWatermark(ctx context.Context, date time.Time) error
|
||||
}
|
||||
|
||||
// channelMonitorRuntimeReader is the optional settings view used to gate V1
|
||||
// active probes by channel_monitor_enabled + channel_monitor_mode.
|
||||
type channelMonitorRuntimeReader interface {
|
||||
GetChannelMonitorRuntime(ctx context.Context) ChannelMonitorRuntime
|
||||
}
|
||||
|
||||
// ChannelMonitorService 渠道监控管理服务。
|
||||
type ChannelMonitorService struct {
|
||||
repo ChannelMonitorRepository
|
||||
encryptor SecretEncryptor
|
||||
// settings is optional; when nil, RunCheck fails closed for active probes
|
||||
// (mode defaults to v2 / retired) so tests without settings never hit upstream.
|
||||
settings channelMonitorRuntimeReader
|
||||
// scheduler 由 wire 通过 SetScheduler 注入;CRUD 后调用对应钩子即时同步任务。
|
||||
// 测试或未注入场景下保持 nil,所有钩子调用变为 no-op。
|
||||
scheduler MonitorScheduler
|
||||
@@ -83,6 +92,22 @@ func NewChannelMonitorService(repo ChannelMonitorRepository, encryptor SecretEnc
|
||||
return &ChannelMonitorService{repo: repo, encryptor: encryptor}
|
||||
}
|
||||
|
||||
// SetRuntimeReader injects the settings reader used to gate active probes.
|
||||
// Optional: when unset, active probes are treated as mode=v2 (retired).
|
||||
func (s *ChannelMonitorService) SetRuntimeReader(r channelMonitorRuntimeReader) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.settings = r
|
||||
}
|
||||
|
||||
func (s *ChannelMonitorService) probeRuntime(ctx context.Context) ChannelMonitorRuntime {
|
||||
if s == nil || s.settings == nil {
|
||||
return ChannelMonitorRuntime{Enabled: true, Mode: ChannelMonitorModeV2}
|
||||
}
|
||||
return s.settings.GetChannelMonitorRuntime(ctx)
|
||||
}
|
||||
|
||||
// ---------- CRUD ----------
|
||||
|
||||
// List 列表查询(支持 provider/enabled/search 过滤 + 分页)。
|
||||
@@ -427,7 +452,16 @@ func (s *ChannelMonitorService) ListHistory(ctx context.Context, id int64, model
|
||||
|
||||
// RunCheck 同步触发对一个监控的检测:并发跑 primary + extra 模型,
|
||||
// 写历史记录并更新 last_checked_at。返回每个模型的检测结果。
|
||||
// 仅当 channel_monitor_enabled=true 且 channel_monitor_mode=v1 时真正探测;
|
||||
// mode=v2 时返回 ErrChannelMonitorActiveProbesRetired,不产生上游流量。
|
||||
func (s *ChannelMonitorService) RunCheck(ctx context.Context, id int64) ([]*CheckResult, error) {
|
||||
rt := s.probeRuntime(ctx)
|
||||
if !rt.Enabled {
|
||||
return nil, ErrChannelMonitorDisabled
|
||||
}
|
||||
if !rt.ActiveProbesAllowed() {
|
||||
return nil, ErrChannelMonitorActiveProbesRetired
|
||||
}
|
||||
m, err := s.Get(ctx, id) // 已解密 APIKey
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -0,0 +1,969 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
ChannelMonitorV2OtherModel = "__other__"
|
||||
ChannelMonitorV2TaxonomyVersion = 1
|
||||
)
|
||||
|
||||
var (
|
||||
ErrChannelMonitorV2InvalidRange = errors.New("invalid channel monitor v2 range")
|
||||
ErrChannelMonitorV2InvalidGroupBy = errors.New("invalid channel monitor v2 group_by")
|
||||
ErrChannelMonitorV2InvalidConfig = errors.New("invalid channel monitor v2 config")
|
||||
ErrChannelMonitorV2ConfigConflict = errors.New("channel monitor v2 config was modified")
|
||||
)
|
||||
|
||||
type ChannelMonitorV2GroupBy string
|
||||
|
||||
const (
|
||||
ChannelMonitorV2GroupByPlatform ChannelMonitorV2GroupBy = "platform"
|
||||
ChannelMonitorV2GroupByPlatformGroup ChannelMonitorV2GroupBy = "platform_group"
|
||||
ChannelMonitorV2GroupByPlatformModel ChannelMonitorV2GroupBy = "platform_model"
|
||||
ChannelMonitorV2GroupByPlatformGroupModel ChannelMonitorV2GroupBy = "platform_group_model"
|
||||
)
|
||||
|
||||
type ChannelMonitorV2PlatformConfig struct {
|
||||
Platform string `json:"platform"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Models []string `json:"models"`
|
||||
}
|
||||
|
||||
type ChannelMonitorV2Config struct {
|
||||
Version int `json:"version"`
|
||||
Enabled bool `json:"enabled"`
|
||||
RefreshIntervalSeconds int `json:"refresh_interval_seconds"`
|
||||
Platforms []ChannelMonitorV2PlatformConfig `json:"platforms"`
|
||||
GroupIDs []int64 `json:"group_ids"`
|
||||
HealthThresholds ChannelMonitorV2HealthThresholds `json:"health_thresholds"`
|
||||
// IgnoredErrorCategories are excluded from error_rate / health scoring.
|
||||
// They still appear in the error breakdown with ignored=true (greyed in UI).
|
||||
// Unknown categories always roll into "other" via the taxonomy classifier.
|
||||
IgnoredErrorCategories []string `json:"ignored_error_categories"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
UpdatedBy *int64 `json:"updated_by,omitempty"`
|
||||
}
|
||||
|
||||
// ChannelMonitorV2ErrorCategories is the ordered, versioned taxonomy used by the
|
||||
// classifier and admin settings UI. Unmatched errors become "other".
|
||||
var ChannelMonitorV2ErrorCategories = []string{
|
||||
"content_policy",
|
||||
"authentication",
|
||||
"context_limit",
|
||||
"invalid_request",
|
||||
"model_unsupported",
|
||||
"group_access",
|
||||
"quota_or_balance",
|
||||
"account_pool_unavailable",
|
||||
"rate_or_capacity",
|
||||
"timeout",
|
||||
"transport_or_stream",
|
||||
"upstream_forbidden",
|
||||
"not_found",
|
||||
"client_cancelled",
|
||||
"upstream_5xx",
|
||||
"internal",
|
||||
"other",
|
||||
}
|
||||
|
||||
type ChannelMonitorV2Filter struct {
|
||||
Range string
|
||||
Platforms []string
|
||||
GroupIDs []int64
|
||||
Models []string
|
||||
Start time.Time
|
||||
End time.Time
|
||||
Bucket time.Duration
|
||||
}
|
||||
|
||||
type ChannelMonitorV2Metric struct {
|
||||
SuccessRequests int64 `json:"success_requests"`
|
||||
ErrorRequests int64 `json:"error_requests"`
|
||||
RequestCount int64 `json:"request_count"`
|
||||
InputTokens int64 `json:"input_tokens"`
|
||||
OutputTokens int64 `json:"output_tokens"`
|
||||
CacheCreationTokens int64 `json:"cache_creation_tokens"`
|
||||
CacheReadTokens int64 `json:"cache_read_tokens"`
|
||||
TokenCount int64 `json:"token_count"`
|
||||
RPM float64 `json:"rpm"`
|
||||
TPM float64 `json:"tpm"`
|
||||
ErrorRate float64 `json:"error_rate"`
|
||||
SuccessRate float64 `json:"success_rate"`
|
||||
CacheRate float64 `json:"cache_rate"`
|
||||
CacheRateNumerator int64 `json:"cache_rate_numerator"`
|
||||
CacheRateDenominator int64 `json:"cache_rate_denominator"`
|
||||
TTFT ChannelMonitorV2Latency `json:"ttft"`
|
||||
Duration ChannelMonitorV2Latency `json:"duration"`
|
||||
UpstreamAffectedRequests *int64 `json:"upstream_affected_requests,omitempty"`
|
||||
UpstreamAttemptCount *int64 `json:"upstream_attempt_count,omitempty"`
|
||||
}
|
||||
|
||||
type ChannelMonitorV2Latency struct {
|
||||
SampleCount int64 `json:"sample_count"`
|
||||
P50Ms *int64 `json:"p50_ms"`
|
||||
P90Ms *int64 `json:"p90_ms"`
|
||||
P95Ms *int64 `json:"p95_ms"`
|
||||
AvgMs *float64 `json:"avg_ms"`
|
||||
}
|
||||
|
||||
type ChannelMonitorV2Health struct {
|
||||
Overall string `json:"overall"`
|
||||
ErrorRate string `json:"error_rate"`
|
||||
TTFT string `json:"ttft"`
|
||||
Cache string `json:"cache"`
|
||||
// Score is 0–100 when samples are sufficient; omitted/null when unknown.
|
||||
// Overall blends error-rate, TTFT p50, and cache rate (weights in Thresholds).
|
||||
Score *float64 `json:"score,omitempty"`
|
||||
ErrorRateScore *float64 `json:"error_rate_score,omitempty"`
|
||||
TTFTScore *float64 `json:"ttft_score,omitempty"`
|
||||
CacheScore *float64 `json:"cache_score,omitempty"`
|
||||
MinimumSample int64 `json:"minimum_sample"`
|
||||
Thresholds ChannelMonitorV2HealthThresholds `json:"thresholds"`
|
||||
}
|
||||
|
||||
type ChannelMonitorV2HealthThresholds struct {
|
||||
// MinimumSample is required before scoring request/latency/cache signals.
|
||||
MinimumSample int64 `json:"minimum_sample"`
|
||||
// WarningErrorRate / CriticalErrorRate map to discrete bands for legacy UI.
|
||||
WarningErrorRate float64 `json:"warning_error_rate"`
|
||||
CriticalErrorRate float64 `json:"critical_error_rate"`
|
||||
// TargetTTFTMs is the primary TTFT p50 budget (ms). At or below this is full TTFT score.
|
||||
TargetTTFTMs int64 `json:"target_ttft_ms"`
|
||||
WarningTTFTMs int64 `json:"warning_ttft_ms"`
|
||||
CriticalTTFTMs int64 `json:"critical_ttft_ms"`
|
||||
// WarningCacheRate / CriticalCacheRate: cache rate below these → warning/critical bands.
|
||||
// Higher cache rate is better; defaults 20% warning / 5% critical.
|
||||
WarningCacheRate float64 `json:"warning_cache_rate"`
|
||||
CriticalCacheRate float64 `json:"critical_cache_rate"`
|
||||
// ErrorWeight + TTFTWeight + CacheWeight should sum to 1.0.
|
||||
ErrorWeight float64 `json:"error_weight"`
|
||||
TTFTWeight float64 `json:"ttft_weight"`
|
||||
CacheWeight float64 `json:"cache_weight"`
|
||||
}
|
||||
|
||||
type ChannelMonitorV2Coverage struct {
|
||||
RequestedStart time.Time `json:"requested_start"`
|
||||
CoverageStart time.Time `json:"coverage_start"`
|
||||
DataThrough time.Time `json:"data_through"`
|
||||
ComputedAt time.Time `json:"computed_at"`
|
||||
AggregationLagSeconds int64 `json:"aggregation_lag_seconds"`
|
||||
CoverageComplete bool `json:"coverage_complete"`
|
||||
BucketSeconds int `json:"bucket_seconds"`
|
||||
}
|
||||
|
||||
type ChannelMonitorV2TrendPoint struct {
|
||||
BucketStart time.Time `json:"bucket_start"`
|
||||
Metrics ChannelMonitorV2Metric `json:"metrics"`
|
||||
Health ChannelMonitorV2Health `json:"health"`
|
||||
}
|
||||
|
||||
type ChannelMonitorV2Snapshot struct {
|
||||
Config ChannelMonitorV2Config `json:"config"`
|
||||
Coverage ChannelMonitorV2Coverage `json:"coverage"`
|
||||
Metrics ChannelMonitorV2Metric `json:"metrics"`
|
||||
Health ChannelMonitorV2Health `json:"health"`
|
||||
Trend []ChannelMonitorV2TrendPoint `json:"trend"`
|
||||
}
|
||||
|
||||
type ChannelMonitorV2Dimension struct {
|
||||
Value string `json:"value"`
|
||||
Label string `json:"label"`
|
||||
Platform string `json:"platform,omitempty"`
|
||||
RequestCount int64 `json:"request_count"`
|
||||
}
|
||||
|
||||
type ChannelMonitorV2GroupDimension struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Platform string `json:"platform,omitempty"`
|
||||
RequestCount int64 `json:"request_count"`
|
||||
}
|
||||
|
||||
type ChannelMonitorV2Dimensions struct {
|
||||
Platforms []ChannelMonitorV2Dimension `json:"platforms"`
|
||||
Groups []ChannelMonitorV2GroupDimension `json:"groups"`
|
||||
Models []ChannelMonitorV2Dimension `json:"models"`
|
||||
}
|
||||
|
||||
type ChannelMonitorV2ModelRow struct {
|
||||
Platform string `json:"platform"`
|
||||
Model string `json:"model"`
|
||||
Metrics ChannelMonitorV2Metric `json:"metrics"`
|
||||
Health ChannelMonitorV2Health `json:"health"`
|
||||
}
|
||||
|
||||
type ChannelMonitorV2MatrixRow struct {
|
||||
Platform string `json:"platform"`
|
||||
GroupID *int64 `json:"group_id,omitempty"`
|
||||
GroupName string `json:"group_name,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
Metrics ChannelMonitorV2Metric `json:"metrics"`
|
||||
Health ChannelMonitorV2Health `json:"health"`
|
||||
Buckets []ChannelMonitorV2TrendPoint `json:"buckets"`
|
||||
}
|
||||
|
||||
type ChannelMonitorV2Matrix struct {
|
||||
GroupBy ChannelMonitorV2GroupBy `json:"group_by"`
|
||||
Coverage ChannelMonitorV2Coverage `json:"coverage"`
|
||||
Items []ChannelMonitorV2MatrixRow `json:"items"`
|
||||
}
|
||||
|
||||
type ChannelMonitorV2ErrorRow struct {
|
||||
Category string `json:"category"`
|
||||
Count int64 `json:"count"`
|
||||
Rate float64 `json:"rate"`
|
||||
Details []ChannelMonitorV2ErrorDetail `json:"details,omitempty"`
|
||||
// Ignored is true when this category is excluded from error_rate scoring
|
||||
// via config.ignored_error_categories. Still shown in breakdown (UI greys it).
|
||||
Ignored bool `json:"ignored"`
|
||||
}
|
||||
|
||||
type ChannelMonitorV2ErrorDetail struct {
|
||||
Platform string `json:"platform,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
ErrorType string `json:"error_type,omitempty"`
|
||||
StatusCode int `json:"status_code,omitempty"`
|
||||
UpstreamStatusCode int `json:"upstream_status_code,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
|
||||
type ChannelMonitorV2UserRow struct {
|
||||
UserID *int64 `json:"user_id,omitempty"`
|
||||
Rank int `json:"rank"`
|
||||
Email string `json:"email,omitempty"`
|
||||
Username string `json:"username,omitempty"`
|
||||
DisplayLabel string `json:"display_label"`
|
||||
IsSelf bool `json:"is_self"`
|
||||
CanDrilldown bool `json:"can_drilldown"`
|
||||
Metrics ChannelMonitorV2Metric `json:"metrics"`
|
||||
}
|
||||
|
||||
type ChannelMonitorV2List[T any] struct {
|
||||
Coverage ChannelMonitorV2Coverage `json:"coverage"`
|
||||
Items []T `json:"items"`
|
||||
}
|
||||
|
||||
type ChannelMonitorV2Repository interface {
|
||||
GetConfig(ctx context.Context) (*ChannelMonitorV2Config, error)
|
||||
UpdateConfig(ctx context.Context, config ChannelMonitorV2Config, expectedVersion int) (*ChannelMonitorV2Config, error)
|
||||
GetDimensions(ctx context.Context, filter ChannelMonitorV2Filter, config ChannelMonitorV2Config) (*ChannelMonitorV2Dimensions, error)
|
||||
GetSnapshot(ctx context.Context, filter ChannelMonitorV2Filter, config ChannelMonitorV2Config, includeAdmin bool) (*ChannelMonitorV2Snapshot, error)
|
||||
GetModels(ctx context.Context, filter ChannelMonitorV2Filter, config ChannelMonitorV2Config, includeAdmin bool) (*ChannelMonitorV2List[ChannelMonitorV2ModelRow], error)
|
||||
GetMatrix(ctx context.Context, filter ChannelMonitorV2Filter, config ChannelMonitorV2Config, groupBy ChannelMonitorV2GroupBy, includeAdmin bool) (*ChannelMonitorV2Matrix, error)
|
||||
// GetErrors loads category rates. When includeAdmin is false, implementations
|
||||
// must omit error Details (no ops_error_logs sample scan) for privacy.
|
||||
GetErrors(ctx context.Context, filter ChannelMonitorV2Filter, config ChannelMonitorV2Config, includeAdmin bool) (*ChannelMonitorV2List[ChannelMonitorV2ErrorRow], error)
|
||||
GetUsers(ctx context.Context, filter ChannelMonitorV2Filter, config ChannelMonitorV2Config, includeAdmin bool) (*ChannelMonitorV2List[ChannelMonitorV2UserRow], error)
|
||||
RecomputeRange(ctx context.Context, start, end time.Time) error
|
||||
}
|
||||
|
||||
type ChannelMonitorV2Service struct {
|
||||
repo ChannelMonitorV2Repository
|
||||
settings channelMonitorRuntimeReader
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewChannelMonitorV2Service(repo ChannelMonitorV2Repository) *ChannelMonitorV2Service {
|
||||
return &ChannelMonitorV2Service{repo: repo, now: func() time.Time { return time.Now().UTC() }}
|
||||
}
|
||||
|
||||
// SetRuntimeReader wires optional settings for privacy flags (hide throughput).
|
||||
func (s *ChannelMonitorV2Service) SetRuntimeReader(r channelMonitorRuntimeReader) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.settings = r
|
||||
}
|
||||
|
||||
func (s *ChannelMonitorV2Service) hideThroughputForViewer(ctx context.Context, admin bool) bool {
|
||||
if admin || s == nil || s.settings == nil {
|
||||
return false
|
||||
}
|
||||
return s.settings.GetChannelMonitorRuntime(ctx).HideThroughput
|
||||
}
|
||||
|
||||
func (s *ChannelMonitorV2Service) GetConfig(ctx context.Context) (*ChannelMonitorV2Config, error) {
|
||||
return s.repo.GetConfig(ctx)
|
||||
}
|
||||
|
||||
func (s *ChannelMonitorV2Service) getEnabledConfig(ctx context.Context) (*ChannelMonitorV2Config, error) {
|
||||
cfg, err := s.repo.GetConfig(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if cfg == nil || !cfg.Enabled {
|
||||
return nil, ErrChannelMonitorDisabled
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func (s *ChannelMonitorV2Service) UpdateConfig(ctx context.Context, cfg ChannelMonitorV2Config, expectedVersion int, actorID int64) (*ChannelMonitorV2Config, error) {
|
||||
if err := normalizeChannelMonitorV2Config(&cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg.UpdatedBy = &actorID
|
||||
return s.repo.UpdateConfig(ctx, cfg, expectedVersion)
|
||||
}
|
||||
|
||||
func (s *ChannelMonitorV2Service) ParseFilter(rangeValue string, platforms, models []string, groupIDs []int64) (ChannelMonitorV2Filter, error) {
|
||||
now := s.now().UTC()
|
||||
var window, bucket time.Duration
|
||||
switch strings.TrimSpace(rangeValue) {
|
||||
case "", "90m":
|
||||
rangeValue, window, bucket = "90m", 90*time.Minute, 5*time.Minute
|
||||
case "24h":
|
||||
window, bucket = 24*time.Hour, time.Hour
|
||||
case "7d":
|
||||
window, bucket = 7*24*time.Hour, 12*time.Hour
|
||||
case "30d":
|
||||
window, bucket = 30*24*time.Hour, 24*time.Hour
|
||||
default:
|
||||
return ChannelMonitorV2Filter{}, fmt.Errorf("%w: %s", ErrChannelMonitorV2InvalidRange, rangeValue)
|
||||
}
|
||||
start, end := now.Add(-window), now
|
||||
if bucket > time.Minute {
|
||||
// Align display windows to a fixed number of whole buckets. The trailing
|
||||
// bucket may point slightly into the future; SQL simply has no future rows,
|
||||
// while the current partial bucket can still be shown and recomputed often.
|
||||
end = now.Truncate(bucket).Add(bucket)
|
||||
start = end.Add(-window)
|
||||
}
|
||||
return ChannelMonitorV2Filter{
|
||||
Range: rangeValue, Platforms: normalizeStringSet(platforms), Models: normalizeStringSet(models), GroupIDs: normalizeInt64Set(groupIDs),
|
||||
Start: start, End: end, Bucket: bucket,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *ChannelMonitorV2Service) Dimensions(ctx context.Context, filter ChannelMonitorV2Filter) (*ChannelMonitorV2Dimensions, error) {
|
||||
cfg, err := s.getEnabledConfig(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dims, err := s.repo.GetDimensions(ctx, filter, *cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Dimension request_count is operational volume; strip for non-admin callers
|
||||
// at the API edge. Dimensions is shared by user/admin routes — redaction is
|
||||
// applied in the handler for user routes only, so keep raw here.
|
||||
return dims, nil
|
||||
}
|
||||
|
||||
func (s *ChannelMonitorV2Service) Snapshot(ctx context.Context, filter ChannelMonitorV2Filter, admin bool) (*ChannelMonitorV2Snapshot, error) {
|
||||
cfg, err := s.getEnabledConfig(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
snap, err := s.repo.GetSnapshot(ctx, filter, *cfg, admin)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !admin && snap != nil {
|
||||
redactChannelMonitorV2Snapshot(snap, s.hideThroughputForViewer(ctx, admin))
|
||||
}
|
||||
return snap, nil
|
||||
}
|
||||
|
||||
func (s *ChannelMonitorV2Service) Models(ctx context.Context, filter ChannelMonitorV2Filter, admin bool) (*ChannelMonitorV2List[ChannelMonitorV2ModelRow], error) {
|
||||
cfg, err := s.getEnabledConfig(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
list, err := s.repo.GetModels(ctx, filter, *cfg, admin)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !admin && list != nil {
|
||||
hideTP := s.hideThroughputForViewer(ctx, admin)
|
||||
for i := range list.Items {
|
||||
redactChannelMonitorV2Metric(&list.Items[i].Metrics, hideTP)
|
||||
}
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
func (s *ChannelMonitorV2Service) Matrix(ctx context.Context, filter ChannelMonitorV2Filter, groupBy ChannelMonitorV2GroupBy, admin bool) (*ChannelMonitorV2Matrix, error) {
|
||||
if !groupBy.Valid() {
|
||||
return nil, fmt.Errorf("%w: %s", ErrChannelMonitorV2InvalidGroupBy, groupBy)
|
||||
}
|
||||
cfg, err := s.getEnabledConfig(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
matrix, err := s.repo.GetMatrix(ctx, filter, *cfg, groupBy, admin)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !admin && matrix != nil {
|
||||
hideTP := s.hideThroughputForViewer(ctx, admin)
|
||||
for i := range matrix.Items {
|
||||
redactChannelMonitorV2Metric(&matrix.Items[i].Metrics, hideTP)
|
||||
for j := range matrix.Items[i].Buckets {
|
||||
redactChannelMonitorV2Metric(&matrix.Items[i].Buckets[j].Metrics, hideTP)
|
||||
}
|
||||
}
|
||||
}
|
||||
return matrix, nil
|
||||
}
|
||||
|
||||
func ParseChannelMonitorV2GroupBy(value string) (ChannelMonitorV2GroupBy, error) {
|
||||
groupBy := ChannelMonitorV2GroupBy(strings.TrimSpace(value))
|
||||
if groupBy == "" {
|
||||
// Default presentation: platform / group (matches operator mental model).
|
||||
groupBy = ChannelMonitorV2GroupByPlatformGroup
|
||||
}
|
||||
if !groupBy.Valid() {
|
||||
return "", fmt.Errorf("%w: %s", ErrChannelMonitorV2InvalidGroupBy, value)
|
||||
}
|
||||
return groupBy, nil
|
||||
}
|
||||
|
||||
func (g ChannelMonitorV2GroupBy) Valid() bool {
|
||||
switch g {
|
||||
case ChannelMonitorV2GroupByPlatform, ChannelMonitorV2GroupByPlatformGroup, ChannelMonitorV2GroupByPlatformModel, ChannelMonitorV2GroupByPlatformGroupModel:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ChannelMonitorV2Service) Errors(ctx context.Context, filter ChannelMonitorV2Filter) (*ChannelMonitorV2List[ChannelMonitorV2ErrorRow], error) {
|
||||
return s.ErrorsForViewer(ctx, filter, false)
|
||||
}
|
||||
|
||||
// ErrorsForViewer returns the error breakdown.
|
||||
// Non-admin callers receive category rates + ignored flags only: absolute Count
|
||||
// is zeroed and Details (upstream messages / status codes / volume) are omitted.
|
||||
func (s *ChannelMonitorV2Service) ErrorsForViewer(ctx context.Context, filter ChannelMonitorV2Filter, admin bool) (*ChannelMonitorV2List[ChannelMonitorV2ErrorRow], error) {
|
||||
cfg, err := s.getEnabledConfig(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
list, err := s.repo.GetErrors(ctx, filter, *cfg, admin)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !admin && list != nil {
|
||||
for i := range list.Items {
|
||||
list.Items[i].Count = 0
|
||||
list.Items[i].Details = nil
|
||||
}
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
// RedactChannelMonitorV2Dimensions clears absolute request counts on filter chips.
|
||||
func RedactChannelMonitorV2Dimensions(dims *ChannelMonitorV2Dimensions) {
|
||||
if dims == nil {
|
||||
return
|
||||
}
|
||||
for i := range dims.Platforms {
|
||||
dims.Platforms[i].RequestCount = 0
|
||||
}
|
||||
for i := range dims.Models {
|
||||
dims.Models[i].RequestCount = 0
|
||||
}
|
||||
for i := range dims.Groups {
|
||||
dims.Groups[i].RequestCount = 0
|
||||
}
|
||||
}
|
||||
|
||||
func redactChannelMonitorV2Snapshot(snap *ChannelMonitorV2Snapshot, hideThroughput bool) {
|
||||
if snap == nil {
|
||||
return
|
||||
}
|
||||
redactChannelMonitorV2Metric(&snap.Metrics, hideThroughput)
|
||||
for i := range snap.Trend {
|
||||
redactChannelMonitorV2Metric(&snap.Trend[i].Metrics, hideThroughput)
|
||||
}
|
||||
// Public snapshot only needs display thresholds + refresh cadence, not
|
||||
// operational allow-lists (group_ids, model inventories, ignored categories).
|
||||
redactChannelMonitorV2PublicConfig(&snap.Config)
|
||||
}
|
||||
|
||||
// redactChannelMonitorV2PublicConfig strips operator-policy fields from config
|
||||
// embedded in user-facing snapshots. Full config remains on admin /config.
|
||||
func redactChannelMonitorV2PublicConfig(cfg *ChannelMonitorV2Config) {
|
||||
if cfg == nil {
|
||||
return
|
||||
}
|
||||
cfg.GroupIDs = nil
|
||||
cfg.IgnoredErrorCategories = nil
|
||||
cfg.UpdatedBy = nil
|
||||
for i := range cfg.Platforms {
|
||||
cfg.Platforms[i].Models = nil
|
||||
}
|
||||
}
|
||||
|
||||
// redactChannelMonitorV2Metric zeros absolute volume counters while keeping rates
|
||||
// (error_rate, success_rate, cache_rate) and latency percentiles.
|
||||
// When hideThroughput is true, also zeros RPM/TPM so users cannot reverse-estimate
|
||||
// fleet scale from rate × window length.
|
||||
func redactChannelMonitorV2Metric(m *ChannelMonitorV2Metric, hideThroughput bool) {
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
m.SuccessRequests = 0
|
||||
m.ErrorRequests = 0
|
||||
m.RequestCount = 0
|
||||
m.InputTokens = 0
|
||||
m.OutputTokens = 0
|
||||
m.CacheCreationTokens = 0
|
||||
m.CacheReadTokens = 0
|
||||
m.TokenCount = 0
|
||||
m.CacheRateNumerator = 0
|
||||
m.CacheRateDenominator = 0
|
||||
// Latency sample_count is also a volume signal.
|
||||
m.TTFT.SampleCount = 0
|
||||
m.Duration.SampleCount = 0
|
||||
m.UpstreamAffectedRequests = nil
|
||||
m.UpstreamAttemptCount = nil
|
||||
if hideThroughput {
|
||||
m.RPM = 0
|
||||
m.TPM = 0
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ChannelMonitorV2Service) Users(ctx context.Context, filter ChannelMonitorV2Filter, viewerID int64, admin bool) (*ChannelMonitorV2List[ChannelMonitorV2UserRow], error) {
|
||||
cfg, err := s.getEnabledConfig(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result, err := s.repo.GetUsers(ctx, filter, *cfg, admin)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if result == nil {
|
||||
result = &ChannelMonitorV2List[ChannelMonitorV2UserRow]{}
|
||||
}
|
||||
selfIndex := -1
|
||||
for i := range result.Items {
|
||||
result.Items[i].Rank = i + 1
|
||||
if result.Items[i].UserID != nil && *result.Items[i].UserID == viewerID {
|
||||
selfIndex = i
|
||||
result.Items[i].IsSelf = true
|
||||
}
|
||||
}
|
||||
// Viewer with no traffic in this window is still shown (and highlighted) so
|
||||
// ranking always answers "where am I?" — not only when already in the top list.
|
||||
if selfIndex < 0 && viewerID > 0 {
|
||||
id := viewerID
|
||||
selfRow := ChannelMonitorV2UserRow{
|
||||
UserID: &id,
|
||||
Rank: 0, // unranked / no traffic in window
|
||||
IsSelf: true,
|
||||
CanDrilldown: true,
|
||||
DisplayLabel: "Me",
|
||||
Metrics: ChannelMonitorV2Metric{},
|
||||
}
|
||||
result.Items = append(result.Items, selfRow)
|
||||
selfIndex = len(result.Items) - 1
|
||||
}
|
||||
result.Items = channelMonitorV2TopUsersWithSelf(result.Items, selfIndex, 10)
|
||||
hideTP := s.hideThroughputForViewer(ctx, admin)
|
||||
if admin {
|
||||
// Keep identity for admin; still mark self for UI highlight.
|
||||
for i := range result.Items {
|
||||
if result.Items[i].UserID != nil && *result.Items[i].UserID == viewerID {
|
||||
result.Items[i].IsSelf = true
|
||||
if result.Items[i].DisplayLabel == "" || result.Items[i].DisplayLabel == "Me" {
|
||||
// Prefer real label when available from repo.
|
||||
if result.Items[i].Username != "" {
|
||||
result.Items[i].DisplayLabel = result.Items[i].Username
|
||||
} else if result.Items[i].Email != "" {
|
||||
result.Items[i].DisplayLabel = result.Items[i].Email
|
||||
} else {
|
||||
result.Items[i].DisplayLabel = "Me"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
for i := range result.Items {
|
||||
redactChannelMonitorV2Metric(&result.Items[i].Metrics, hideTP)
|
||||
}
|
||||
for i := range result.Items {
|
||||
row := &result.Items[i]
|
||||
if row.UserID != nil && *row.UserID == viewerID {
|
||||
row.IsSelf, row.CanDrilldown, row.DisplayLabel = true, true, "Me"
|
||||
continue
|
||||
}
|
||||
row.UserID, row.Email, row.Username, row.CanDrilldown = nil, "", "", false
|
||||
row.DisplayLabel = fmt.Sprintf("Other user #%d", i+1)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func channelMonitorV2TopUsersWithSelf(items []ChannelMonitorV2UserRow, selfIndex int, limit int) []ChannelMonitorV2UserRow {
|
||||
if limit <= 0 {
|
||||
return items
|
||||
}
|
||||
if len(items) <= limit {
|
||||
return items
|
||||
}
|
||||
out := append([]ChannelMonitorV2UserRow(nil), items[:limit]...)
|
||||
if selfIndex >= limit && selfIndex < len(items) {
|
||||
out = append(out, items[selfIndex])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizeChannelMonitorV2Config(cfg *ChannelMonitorV2Config) error {
|
||||
if cfg.RefreshIntervalSeconds == 0 {
|
||||
cfg.RefreshIntervalSeconds = 300
|
||||
}
|
||||
if cfg.RefreshIntervalSeconds != 60 && cfg.RefreshIntervalSeconds != 300 {
|
||||
return fmt.Errorf("%w: refresh_interval_seconds must be 60 or 300", ErrChannelMonitorV2InvalidConfig)
|
||||
}
|
||||
var err error
|
||||
cfg.GroupIDs, err = normalizeChannelMonitorV2GroupIDs(cfg.GroupIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg.IgnoredErrorCategories = normalizeChannelMonitorV2IgnoredCategories(cfg.IgnoredErrorCategories)
|
||||
cfg.HealthThresholds = NormalizeChannelMonitorV2HealthThresholds(cfg.HealthThresholds)
|
||||
seen := make(map[string]struct{}, len(cfg.Platforms))
|
||||
for i := range cfg.Platforms {
|
||||
p := &cfg.Platforms[i]
|
||||
p.Platform = strings.ToLower(strings.TrimSpace(p.Platform))
|
||||
if p.Platform == "" {
|
||||
return fmt.Errorf("%w: empty platform", ErrChannelMonitorV2InvalidConfig)
|
||||
}
|
||||
if _, ok := seen[p.Platform]; ok {
|
||||
return fmt.Errorf("%w: duplicate platform %s", ErrChannelMonitorV2InvalidConfig, p.Platform)
|
||||
}
|
||||
seen[p.Platform] = struct{}{}
|
||||
p.Models = normalizeStringSet(p.Models)
|
||||
}
|
||||
sort.Slice(cfg.Platforms, func(i, j int) bool { return cfg.Platforms[i].Platform < cfg.Platforms[j].Platform })
|
||||
return nil
|
||||
}
|
||||
|
||||
// DefaultChannelMonitorV2IgnoredErrorCategories are factory defaults for
|
||||
// ignored_error_categories: excluded from error_rate / health scoring only.
|
||||
// Operators can clear or extend via admin config.
|
||||
var DefaultChannelMonitorV2IgnoredErrorCategories = []string{
|
||||
"authentication",
|
||||
"client_cancelled",
|
||||
"content_policy",
|
||||
"context_limit",
|
||||
"group_access",
|
||||
"model_unsupported",
|
||||
"not_found",
|
||||
"quota_or_balance",
|
||||
}
|
||||
|
||||
func DefaultChannelMonitorV2HealthThresholds() ChannelMonitorV2HealthThresholds {
|
||||
return ChannelMonitorV2HealthThresholds{
|
||||
MinimumSample: 50,
|
||||
WarningErrorRate: 0.05,
|
||||
CriticalErrorRate: 0.20,
|
||||
TargetTTFTMs: 3000,
|
||||
WarningTTFTMs: 3000,
|
||||
CriticalTTFTMs: 10000,
|
||||
// A zero/zero cache threshold means cache misses do not affect health
|
||||
// until an operator explicitly configures cache scoring.
|
||||
WarningCacheRate: 0,
|
||||
CriticalCacheRate: 0,
|
||||
ErrorWeight: 0.60,
|
||||
TTFTWeight: 0.20,
|
||||
CacheWeight: 0.20,
|
||||
}
|
||||
}
|
||||
|
||||
func NormalizeChannelMonitorV2HealthThresholds(in ChannelMonitorV2HealthThresholds) ChannelMonitorV2HealthThresholds {
|
||||
def := DefaultChannelMonitorV2HealthThresholds()
|
||||
if in.MinimumSample <= 0 {
|
||||
in.MinimumSample = def.MinimumSample
|
||||
}
|
||||
if in.MinimumSample < 1 {
|
||||
in.MinimumSample = 1
|
||||
}
|
||||
if in.MinimumSample > 10000 {
|
||||
in.MinimumSample = 10000
|
||||
}
|
||||
if in.WarningErrorRate <= 0 {
|
||||
in.WarningErrorRate = def.WarningErrorRate
|
||||
}
|
||||
if in.CriticalErrorRate <= 0 {
|
||||
in.CriticalErrorRate = def.CriticalErrorRate
|
||||
}
|
||||
if in.CriticalErrorRate < in.WarningErrorRate {
|
||||
in.CriticalErrorRate = in.WarningErrorRate
|
||||
}
|
||||
if in.TargetTTFTMs <= 0 {
|
||||
in.TargetTTFTMs = def.TargetTTFTMs
|
||||
}
|
||||
if in.WarningTTFTMs <= 0 {
|
||||
in.WarningTTFTMs = def.WarningTTFTMs
|
||||
}
|
||||
if in.WarningTTFTMs < in.TargetTTFTMs {
|
||||
in.WarningTTFTMs = in.TargetTTFTMs + 1
|
||||
}
|
||||
if in.CriticalTTFTMs <= 0 {
|
||||
in.CriticalTTFTMs = def.CriticalTTFTMs
|
||||
}
|
||||
if in.CriticalTTFTMs < in.WarningTTFTMs {
|
||||
in.CriticalTTFTMs = in.WarningTTFTMs
|
||||
}
|
||||
if in.WarningCacheRate < 0 {
|
||||
in.WarningCacheRate = 0
|
||||
}
|
||||
if in.CriticalCacheRate < 0 {
|
||||
in.CriticalCacheRate = 0
|
||||
}
|
||||
if in.WarningCacheRate > 1 {
|
||||
in.WarningCacheRate = 1
|
||||
}
|
||||
if in.CriticalCacheRate > 1 {
|
||||
in.CriticalCacheRate = 1
|
||||
}
|
||||
if in.CriticalCacheRate > in.WarningCacheRate {
|
||||
in.CriticalCacheRate = in.WarningCacheRate
|
||||
}
|
||||
if in.ErrorWeight <= 0 && in.TTFTWeight <= 0 && in.CacheWeight <= 0 {
|
||||
in.ErrorWeight, in.TTFTWeight, in.CacheWeight = def.ErrorWeight, def.TTFTWeight, def.CacheWeight
|
||||
}
|
||||
return in
|
||||
}
|
||||
|
||||
func normalizeChannelMonitorV2IgnoredCategories(values []string) []string {
|
||||
allowed := make(map[string]struct{}, len(ChannelMonitorV2ErrorCategories))
|
||||
for _, c := range ChannelMonitorV2ErrorCategories {
|
||||
allowed[c] = struct{}{}
|
||||
}
|
||||
seen := make(map[string]struct{}, len(values))
|
||||
out := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := allowed[value]; !ok {
|
||||
// Unknown labels are dropped so a typo cannot silently create a new category.
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[value]; ok {
|
||||
continue
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
out = append(out, value)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// ChannelMonitorV2IgnoredCategorySet returns a set for O(1) membership checks.
|
||||
func ChannelMonitorV2IgnoredCategorySet(cfg ChannelMonitorV2Config) map[string]struct{} {
|
||||
set := make(map[string]struct{}, len(cfg.IgnoredErrorCategories))
|
||||
for _, c := range cfg.IgnoredErrorCategories {
|
||||
set[c] = struct{}{}
|
||||
}
|
||||
return set
|
||||
}
|
||||
|
||||
func normalizeStringSet(values []string) []string {
|
||||
seen := make(map[string]struct{}, len(values))
|
||||
out := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[value]; ok {
|
||||
continue
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
out = append(out, value)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizeInt64Set(values []int64) []int64 {
|
||||
seen := make(map[int64]struct{}, len(values))
|
||||
out := make([]int64, 0, len(values))
|
||||
for _, value := range values {
|
||||
if value <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[value]; ok {
|
||||
continue
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
out = append(out, value)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizeChannelMonitorV2GroupIDs(values []int64) ([]int64, error) {
|
||||
seen := make(map[int64]struct{}, len(values))
|
||||
out := make([]int64, 0, len(values))
|
||||
for _, value := range values {
|
||||
if value <= 0 {
|
||||
return nil, fmt.Errorf("%w: group_ids must be positive", ErrChannelMonitorV2InvalidConfig)
|
||||
}
|
||||
if _, ok := seen[value]; ok {
|
||||
continue
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
out = append(out, value)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func ChannelMonitorV2HealthFor(metrics ChannelMonitorV2Metric) ChannelMonitorV2Health {
|
||||
return ChannelMonitorV2HealthForWithThresholds(metrics, DefaultChannelMonitorV2HealthThresholds())
|
||||
}
|
||||
|
||||
func ChannelMonitorV2HealthForWithThresholds(metrics ChannelMonitorV2Metric, thresholds ChannelMonitorV2HealthThresholds) ChannelMonitorV2Health {
|
||||
thresholds = NormalizeChannelMonitorV2HealthThresholds(thresholds)
|
||||
result := ChannelMonitorV2Health{
|
||||
Overall: "unknown", ErrorRate: "unknown", TTFT: "unknown", Cache: "unknown",
|
||||
MinimumSample: thresholds.MinimumSample, Thresholds: thresholds,
|
||||
}
|
||||
|
||||
type scored struct {
|
||||
score float64
|
||||
weight float64
|
||||
band string
|
||||
}
|
||||
parts := make([]scored, 0, 3)
|
||||
|
||||
if metrics.RequestCount >= result.MinimumSample {
|
||||
s := errorRateScore(metrics.ErrorRate, thresholds.CriticalErrorRate)
|
||||
result.ErrorRateScore = &s
|
||||
result.ErrorRate = healthBand(metrics.ErrorRate, thresholds.WarningErrorRate, thresholds.CriticalErrorRate)
|
||||
parts = append(parts, scored{score: s, weight: thresholds.ErrorWeight, band: result.ErrorRate})
|
||||
}
|
||||
// Prefer p50 for TTFT scoring; fall back to p95 only if p50 is missing.
|
||||
if metrics.TTFT.SampleCount >= result.MinimumSample {
|
||||
var ttftMs *int64
|
||||
if metrics.TTFT.P50Ms != nil {
|
||||
ttftMs = metrics.TTFT.P50Ms
|
||||
} else if metrics.TTFT.P95Ms != nil {
|
||||
ttftMs = metrics.TTFT.P95Ms
|
||||
}
|
||||
if ttftMs != nil {
|
||||
s := ttftP50Score(float64(*ttftMs), float64(thresholds.TargetTTFTMs), float64(thresholds.CriticalTTFTMs))
|
||||
result.TTFTScore = &s
|
||||
result.TTFT = healthBand(float64(*ttftMs), float64(thresholds.WarningTTFTMs), float64(thresholds.CriticalTTFTMs))
|
||||
parts = append(parts, scored{score: s, weight: thresholds.TTFTWeight, band: result.TTFT})
|
||||
}
|
||||
}
|
||||
// Cache: need a meaningful denominator; higher rate is better.
|
||||
if metrics.CacheRateDenominator >= result.MinimumSample {
|
||||
s := cacheRateScore(metrics.CacheRate)
|
||||
if thresholds.WarningCacheRate <= 0 && thresholds.CriticalCacheRate <= 0 {
|
||||
// A zero/zero cache threshold means "do not penalize cache misses".
|
||||
s = 100
|
||||
}
|
||||
result.CacheScore = &s
|
||||
// Invert for healthBand (lower is worse): use (1 - rate) against warning/critical floors.
|
||||
result.Cache = cacheRateBand(metrics.CacheRate, thresholds.WarningCacheRate, thresholds.CriticalCacheRate)
|
||||
parts = append(parts, scored{score: s, weight: thresholds.CacheWeight, band: result.Cache})
|
||||
}
|
||||
|
||||
if len(parts) == 0 {
|
||||
return result
|
||||
}
|
||||
var weightSum, scoreSum float64
|
||||
for _, p := range parts {
|
||||
weightSum += p.weight
|
||||
scoreSum += p.weight * p.score
|
||||
}
|
||||
if weightSum <= 0 {
|
||||
return result
|
||||
}
|
||||
overall := scoreSum / weightSum
|
||||
result.Score = &overall
|
||||
result.Overall = scoreBand(overall)
|
||||
return result
|
||||
}
|
||||
|
||||
// errorRateScore maps error rate to 0–100. 0% → 100; at/above critical → 0 (linear).
|
||||
func errorRateScore(errorRate, critical float64) float64 {
|
||||
if critical <= 0 {
|
||||
critical = 0.05
|
||||
}
|
||||
if errorRate <= 0 {
|
||||
return 100
|
||||
}
|
||||
if errorRate >= critical {
|
||||
return 0
|
||||
}
|
||||
return 100 * (1 - errorRate/critical)
|
||||
}
|
||||
|
||||
// ttftP50Score maps TTFT p50 ms to 0–100.
|
||||
// At/below target → 100; at/above critical → 0; linear in between.
|
||||
func ttftP50Score(p50Ms, targetMs, criticalMs float64) float64 {
|
||||
if targetMs <= 0 {
|
||||
targetMs = 2500
|
||||
}
|
||||
if criticalMs <= targetMs {
|
||||
criticalMs = targetMs * 2.4
|
||||
}
|
||||
if p50Ms <= targetMs {
|
||||
return 100
|
||||
}
|
||||
if p50Ms >= criticalMs {
|
||||
return 0
|
||||
}
|
||||
return 100 * (1 - (p50Ms-targetMs)/(criticalMs-targetMs))
|
||||
}
|
||||
|
||||
// cacheRateScore maps cache hit rate to 0–100 (higher is better, linear).
|
||||
func cacheRateScore(cacheRate float64) float64 {
|
||||
if cacheRate <= 0 {
|
||||
return 0
|
||||
}
|
||||
if cacheRate >= 1 {
|
||||
return 100
|
||||
}
|
||||
return 100 * cacheRate
|
||||
}
|
||||
|
||||
// cacheRateBand: below critical → critical; below warning → warning; else healthy.
|
||||
func cacheRateBand(cacheRate, warning, critical float64) string {
|
||||
if cacheRate < critical {
|
||||
return "critical"
|
||||
}
|
||||
if cacheRate < warning {
|
||||
return "warning"
|
||||
}
|
||||
return "healthy"
|
||||
}
|
||||
|
||||
// scoreBand maps continuous 0–100 scores to coarse labels for legacy consumers.
|
||||
func scoreBand(score float64) string {
|
||||
switch {
|
||||
case score >= 80:
|
||||
return "healthy"
|
||||
case score >= 50:
|
||||
return "warning"
|
||||
default:
|
||||
return "critical"
|
||||
}
|
||||
}
|
||||
|
||||
func healthBand(value, warning, critical float64) string {
|
||||
if value >= critical {
|
||||
return "critical"
|
||||
}
|
||||
if value >= warning {
|
||||
return "warning"
|
||||
}
|
||||
return "healthy"
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const (
|
||||
channelMonitorV2AggregatorLockKey = "channel-monitor-v2-aggregator"
|
||||
// Backfill walks back to the longest stored tier (1d rollup = 90d). Per-tier
|
||||
// prune in the repository drops short-lived 1m/user/hist facts earlier.
|
||||
channelMonitorV2RetentionMax = 90 * 24 * time.Hour
|
||||
channelMonitorV2RecentOverlap = 10 * time.Minute
|
||||
channelMonitorV2InitialWindow = 2 * time.Hour
|
||||
channelMonitorV2BackfillChunk = 24 * time.Hour
|
||||
)
|
||||
|
||||
// channelMonitorRuntimeSubscriber is the optional settings hook that lets the
|
||||
// aggregator wake immediately when channel_monitor_enabled / mode flips.
|
||||
type channelMonitorRuntimeSubscriber interface {
|
||||
SubscribeChannelMonitorRuntime(listener func()) (unsubscribe func())
|
||||
}
|
||||
|
||||
type ChannelMonitorV2Aggregator struct {
|
||||
repo ChannelMonitorV2Repository
|
||||
db *sql.DB
|
||||
settings channelMonitorRuntimeReader
|
||||
instanceID string
|
||||
stopCh chan struct{}
|
||||
// kickCh wakes the loop early after a settings change (buffered 1).
|
||||
kickCh chan struct{}
|
||||
startOnce sync.Once
|
||||
stopOnce sync.Once
|
||||
mu sync.Mutex
|
||||
backfillAt time.Time
|
||||
unsub func()
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
func NewChannelMonitorV2Aggregator(repo ChannelMonitorV2Repository, db *sql.DB, settings channelMonitorRuntimeReader) *ChannelMonitorV2Aggregator {
|
||||
return &ChannelMonitorV2Aggregator{
|
||||
repo: repo,
|
||||
db: db,
|
||||
settings: settings,
|
||||
instanceID: uuid.NewString(),
|
||||
stopCh: make(chan struct{}),
|
||||
kickCh: make(chan struct{}, 1),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ChannelMonitorV2Aggregator) Start() {
|
||||
if s == nil || s.repo == nil {
|
||||
return
|
||||
}
|
||||
s.startOnce.Do(func() {
|
||||
s.mu.Lock()
|
||||
s.ctx, s.cancel = context.WithCancel(context.Background())
|
||||
s.mu.Unlock()
|
||||
if sub, ok := s.settings.(channelMonitorRuntimeSubscriber); ok && sub != nil {
|
||||
unsub := sub.SubscribeChannelMonitorRuntime(func() {
|
||||
s.kick()
|
||||
})
|
||||
s.mu.Lock()
|
||||
stopped := s.ctx == nil
|
||||
if !stopped {
|
||||
select {
|
||||
case <-s.ctx.Done():
|
||||
stopped = true
|
||||
default:
|
||||
}
|
||||
}
|
||||
if !stopped {
|
||||
s.unsub = unsub
|
||||
}
|
||||
s.mu.Unlock()
|
||||
if stopped && unsub != nil {
|
||||
unsub()
|
||||
}
|
||||
}
|
||||
go s.loop()
|
||||
})
|
||||
}
|
||||
|
||||
func (s *ChannelMonitorV2Aggregator) Stop() {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.stopOnce.Do(func() {
|
||||
s.mu.Lock()
|
||||
cancel := s.cancel
|
||||
unsub := s.unsub
|
||||
s.cancel = nil
|
||||
s.unsub = nil
|
||||
s.mu.Unlock()
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
}
|
||||
if unsub != nil {
|
||||
unsub()
|
||||
}
|
||||
close(s.stopCh)
|
||||
})
|
||||
}
|
||||
|
||||
// kick wakes the aggregation loop so mode flips take effect without waiting
|
||||
// for the next refresh interval.
|
||||
func (s *ChannelMonitorV2Aggregator) kick() {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case s.kickCh <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ChannelMonitorV2Aggregator) loop() {
|
||||
for {
|
||||
interval := time.Minute
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
if !s.passiveAggregationAllowed(ctx) {
|
||||
cancel()
|
||||
if !s.wait(interval) {
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
if cfg, err := s.repo.GetConfig(ctx); err == nil {
|
||||
if !cfg.Enabled {
|
||||
cancel()
|
||||
if !s.wait(interval) {
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
if cfg.RefreshIntervalSeconds > 0 {
|
||||
interval = time.Duration(cfg.RefreshIntervalSeconds) * time.Second
|
||||
}
|
||||
}
|
||||
cancel()
|
||||
s.runOnce()
|
||||
if !s.wait(interval) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ChannelMonitorV2Aggregator) passiveAggregationAllowed(ctx context.Context) bool {
|
||||
if s == nil || s.settings == nil {
|
||||
// Fail closed without settings: do not aggregate under ambiguous mode.
|
||||
return false
|
||||
}
|
||||
return s.settings.GetChannelMonitorRuntime(ctx).PassiveAggregationAllowed()
|
||||
}
|
||||
|
||||
func (s *ChannelMonitorV2Aggregator) wait(interval time.Duration) bool {
|
||||
timer := time.NewTimer(interval)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-timer.C:
|
||||
return true
|
||||
case <-s.kickCh:
|
||||
// Drain any coalesced kicks so a burst of settings writes only wakes once.
|
||||
for {
|
||||
select {
|
||||
case <-s.kickCh:
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
case <-s.stopCh:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ChannelMonitorV2Aggregator) runOnce() {
|
||||
s.mu.Lock()
|
||||
parent := s.ctx
|
||||
s.mu.Unlock()
|
||||
if parent == nil {
|
||||
parent = context.Background()
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(parent, 55*time.Second)
|
||||
defer cancel()
|
||||
release, acquired := tryAcquireSingletonLeaderLock(ctx, nil, s.db, channelMonitorV2AggregatorLockKey, s.instanceID, 2*time.Minute)
|
||||
if !acquired {
|
||||
return
|
||||
}
|
||||
if release != nil {
|
||||
defer release()
|
||||
}
|
||||
|
||||
now := time.Now().UTC().Truncate(time.Minute)
|
||||
if s.backfillAt.IsZero() {
|
||||
start := now.Add(-channelMonitorV2InitialWindow)
|
||||
if err := s.repo.RecomputeRange(ctx, start, now); err != nil {
|
||||
logger.LegacyPrintf("service.channel_monitor_v2", "[ChannelMonitorV2] recent aggregation failed: %v", err)
|
||||
return
|
||||
}
|
||||
s.backfillAt = start
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.repo.RecomputeRange(ctx, now.Add(-channelMonitorV2RecentOverlap), now); err != nil {
|
||||
logger.LegacyPrintf("service.channel_monitor_v2", "[ChannelMonitorV2] overlap aggregation failed: %v", err)
|
||||
return
|
||||
}
|
||||
cutoff := now.Add(-channelMonitorV2RetentionMax)
|
||||
if s.backfillAt.After(cutoff) {
|
||||
end := s.backfillAt
|
||||
start := end.Add(-channelMonitorV2BackfillChunk)
|
||||
if start.Before(cutoff) {
|
||||
start = cutoff
|
||||
}
|
||||
if err := s.repo.RecomputeRange(ctx, start, end); err != nil {
|
||||
logger.LegacyPrintf("service.channel_monitor_v2", "[ChannelMonitorV2] backfill failed %s..%s: %v", start, end, err)
|
||||
return
|
||||
}
|
||||
s.backfillAt = start
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package service
|
||||
|
||||
import "strings"
|
||||
|
||||
type ChannelMonitorV2ErrorInput struct {
|
||||
ErrorType string
|
||||
ErrorOwner string
|
||||
ErrorSource string
|
||||
StatusCode int
|
||||
UpstreamStatusCode int
|
||||
Message string
|
||||
}
|
||||
|
||||
// ClassifyChannelMonitorV2Error is deliberately ordered. Once rows are stored
|
||||
// with a taxonomy version, changing this order requires a new version.
|
||||
func ClassifyChannelMonitorV2Error(input ChannelMonitorV2ErrorInput) string {
|
||||
errorType := strings.ToLower(strings.TrimSpace(input.ErrorType))
|
||||
owner := strings.ToLower(strings.TrimSpace(input.ErrorOwner))
|
||||
text := strings.ToLower(strings.Join([]string{input.ErrorType, input.ErrorSource, input.Message}, " "))
|
||||
|
||||
if errorType == "cyber_policy" || channelMonitorV2ContainsAny(text, "content policy", "content_policy", "safety policy", "moderation", "blocked keyword") {
|
||||
return "content_policy"
|
||||
}
|
||||
if input.StatusCode == 401 || input.UpstreamStatusCode == 401 || channelMonitorV2ContainsAny(text, "unauthorized", "invalid api key", "invalid_api_key", "authentication", "api_key_disabled") {
|
||||
return "authentication"
|
||||
}
|
||||
if channelMonitorV2ContainsAny(text, "context window", "context length", "maximum prompt length", "too many tokens", "max_tokens") {
|
||||
return "context_limit"
|
||||
}
|
||||
if channelMonitorV2ContainsAny(text, "failed to deserialize", "missing required parameter", "invalid request", "invalid_request", "tool_choice") {
|
||||
return "invalid_request"
|
||||
}
|
||||
if channelMonitorV2ContainsAny(text, "does not support the requested model", "not supported by any configured account", "model not supported", "unsupported model") {
|
||||
return "model_unsupported"
|
||||
}
|
||||
if channelMonitorV2ContainsAny(text, "group not allowed", "group_not_allowed", "group access") {
|
||||
return "group_access"
|
||||
}
|
||||
if channelMonitorV2ContainsAny(text, "run out of credits", "insufficient balance", "insufficient quota", "subscription", "quota exceeded", "billing hard limit") {
|
||||
return "quota_or_balance"
|
||||
}
|
||||
if channelMonitorV2ContainsAny(text, "no available accounts", "no healthy account", "no healthy upstream account", "failover budget exhausted", "account pool") {
|
||||
return "account_pool_unavailable"
|
||||
}
|
||||
if input.StatusCode == 429 || input.UpstreamStatusCode == 429 || channelMonitorV2ContainsAny(text, "rate limit", "rate_limit", "high demand", "overloaded", "concurrency limit", "capacity") {
|
||||
return "rate_or_capacity"
|
||||
}
|
||||
if input.StatusCode == 408 || input.StatusCode == 504 || channelMonitorV2ContainsAny(text, "timeout", "deadline exceeded", "error code: 524", "gateway time-out", "gateway timeout") {
|
||||
return "timeout"
|
||||
}
|
||||
if channelMonitorV2ContainsAny(text, "transport", "stream_read_error", "connection reset", "connection refused", "tls", "http2", "missing terminal event", "unexpected eof") {
|
||||
return "transport_or_stream"
|
||||
}
|
||||
if input.StatusCode == 403 || input.UpstreamStatusCode == 403 {
|
||||
return "upstream_forbidden"
|
||||
}
|
||||
if input.StatusCode == 404 || input.UpstreamStatusCode == 404 {
|
||||
return "not_found"
|
||||
}
|
||||
if input.StatusCode == 499 || channelMonitorV2ContainsAny(text, "client cancelled", "client canceled", "context canceled") {
|
||||
return "client_cancelled"
|
||||
}
|
||||
if input.UpstreamStatusCode >= 500 || (owner == "provider" && input.StatusCode >= 500) {
|
||||
return "upstream_5xx"
|
||||
}
|
||||
if input.StatusCode >= 500 || errorType == "internal" || owner == "system" {
|
||||
return "internal"
|
||||
}
|
||||
return "other"
|
||||
}
|
||||
|
||||
func channelMonitorV2ContainsAny(value string, needles ...string) bool {
|
||||
for _, needle := range needles {
|
||||
if strings.Contains(value, needle) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type channelMonitorV2RepoStub struct {
|
||||
config serviceChannelMonitorV2ConfigAlias
|
||||
users *ChannelMonitorV2List[ChannelMonitorV2UserRow]
|
||||
matrix *ChannelMonitorV2Matrix
|
||||
errors *ChannelMonitorV2List[ChannelMonitorV2ErrorRow]
|
||||
snap *ChannelMonitorV2Snapshot
|
||||
group ChannelMonitorV2GroupBy
|
||||
admin bool
|
||||
}
|
||||
|
||||
// Alias keeps composite literals readable without introducing another package.
|
||||
type serviceChannelMonitorV2ConfigAlias = ChannelMonitorV2Config
|
||||
|
||||
func (s *channelMonitorV2RepoStub) GetConfig(context.Context) (*ChannelMonitorV2Config, error) {
|
||||
cfg := ChannelMonitorV2Config(s.config)
|
||||
return &cfg, nil
|
||||
}
|
||||
func (s *channelMonitorV2RepoStub) UpdateConfig(context.Context, ChannelMonitorV2Config, int) (*ChannelMonitorV2Config, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *channelMonitorV2RepoStub) GetDimensions(context.Context, ChannelMonitorV2Filter, ChannelMonitorV2Config) (*ChannelMonitorV2Dimensions, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *channelMonitorV2RepoStub) GetSnapshot(_ context.Context, _ ChannelMonitorV2Filter, _ ChannelMonitorV2Config, admin bool) (*ChannelMonitorV2Snapshot, error) {
|
||||
s.admin = admin
|
||||
if s.snap == nil {
|
||||
return nil, nil
|
||||
}
|
||||
// Shallow copy so public redaction does not mutate fixture.
|
||||
cfg := s.snap.Config
|
||||
cfg.Platforms = append([]ChannelMonitorV2PlatformConfig(nil), s.snap.Config.Platforms...)
|
||||
for i := range cfg.Platforms {
|
||||
cfg.Platforms[i].Models = append([]string(nil), s.snap.Config.Platforms[i].Models...)
|
||||
}
|
||||
cfg.GroupIDs = append([]int64(nil), s.snap.Config.GroupIDs...)
|
||||
cfg.IgnoredErrorCategories = append([]string(nil), s.snap.Config.IgnoredErrorCategories...)
|
||||
out := *s.snap
|
||||
out.Config = cfg
|
||||
return &out, nil
|
||||
}
|
||||
func (s *channelMonitorV2RepoStub) GetModels(context.Context, ChannelMonitorV2Filter, ChannelMonitorV2Config, bool) (*ChannelMonitorV2List[ChannelMonitorV2ModelRow], error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *channelMonitorV2RepoStub) GetMatrix(_ context.Context, _ ChannelMonitorV2Filter, _ ChannelMonitorV2Config, groupBy ChannelMonitorV2GroupBy, admin bool) (*ChannelMonitorV2Matrix, error) {
|
||||
s.group, s.admin = groupBy, admin
|
||||
return s.matrix, nil
|
||||
}
|
||||
func (s *channelMonitorV2RepoStub) GetErrors(_ context.Context, _ ChannelMonitorV2Filter, _ ChannelMonitorV2Config, admin bool) (*ChannelMonitorV2List[ChannelMonitorV2ErrorRow], error) {
|
||||
s.admin = admin
|
||||
if s.errors != nil {
|
||||
// Return a shallow copy so service redaction does not mutate the fixture.
|
||||
out := &ChannelMonitorV2List[ChannelMonitorV2ErrorRow]{
|
||||
Coverage: s.errors.Coverage,
|
||||
Items: append([]ChannelMonitorV2ErrorRow(nil), s.errors.Items...),
|
||||
}
|
||||
for i := range out.Items {
|
||||
if len(out.Items[i].Details) > 0 {
|
||||
out.Items[i].Details = append([]ChannelMonitorV2ErrorDetail(nil), out.Items[i].Details...)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
func (s *channelMonitorV2RepoStub) GetUsers(context.Context, ChannelMonitorV2Filter, ChannelMonitorV2Config, bool) (*ChannelMonitorV2List[ChannelMonitorV2UserRow], error) {
|
||||
return s.users, nil
|
||||
}
|
||||
func (s *channelMonitorV2RepoStub) RecomputeRange(context.Context, time.Time, time.Time) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestChannelMonitorV2ParseFilterDefaultsAndBuckets(t *testing.T) {
|
||||
svc := &ChannelMonitorV2Service{now: func() time.Time { return time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC) }}
|
||||
|
||||
filter, err := svc.ParseFilter("", []string{"openai", "openai", ""}, []string{"gpt-5"}, []int64{2, 1, 2, 0})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "90m", filter.Range)
|
||||
require.Equal(t, 5*time.Minute, filter.Bucket)
|
||||
require.Equal(t, []string{"openai"}, filter.Platforms)
|
||||
require.Equal(t, []int64{1, 2}, filter.GroupIDs)
|
||||
require.Equal(t, 90*time.Minute, filter.End.Sub(filter.Start))
|
||||
|
||||
filter, err = svc.ParseFilter("30d", nil, nil, nil)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 24*time.Hour, filter.Bucket)
|
||||
_, err = svc.ParseFilter("15d", nil, nil, nil)
|
||||
require.ErrorIs(t, err, ErrChannelMonitorV2InvalidRange)
|
||||
}
|
||||
|
||||
func TestParseChannelMonitorV2GroupBy(t *testing.T) {
|
||||
groupBy, err := ParseChannelMonitorV2GroupBy("")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, ChannelMonitorV2GroupByPlatformGroup, groupBy)
|
||||
for _, value := range []ChannelMonitorV2GroupBy{ChannelMonitorV2GroupByPlatform, ChannelMonitorV2GroupByPlatformGroup, ChannelMonitorV2GroupByPlatformModel, ChannelMonitorV2GroupByPlatformGroupModel} {
|
||||
parsed, parseErr := ParseChannelMonitorV2GroupBy(string(value))
|
||||
require.NoError(t, parseErr)
|
||||
require.Equal(t, value, parsed)
|
||||
}
|
||||
_, err = ParseChannelMonitorV2GroupBy("group")
|
||||
require.ErrorIs(t, err, ErrChannelMonitorV2InvalidGroupBy)
|
||||
}
|
||||
|
||||
func TestChannelMonitorV2MatrixForwardsGroupingAndAdminScope(t *testing.T) {
|
||||
want := &ChannelMonitorV2Matrix{GroupBy: ChannelMonitorV2GroupByPlatformModel}
|
||||
repo := &channelMonitorV2RepoStub{config: ChannelMonitorV2Config{Enabled: true}, matrix: want}
|
||||
result, err := NewChannelMonitorV2Service(repo).Matrix(context.Background(), ChannelMonitorV2Filter{}, ChannelMonitorV2GroupByPlatformModel, true)
|
||||
require.NoError(t, err)
|
||||
require.Same(t, want, result)
|
||||
require.Equal(t, ChannelMonitorV2GroupByPlatformModel, repo.group)
|
||||
require.True(t, repo.admin)
|
||||
|
||||
_, err = NewChannelMonitorV2Service(repo).Matrix(context.Background(), ChannelMonitorV2Filter{}, "bad", false)
|
||||
require.ErrorIs(t, err, ErrChannelMonitorV2InvalidGroupBy)
|
||||
}
|
||||
|
||||
func TestChannelMonitorV2ConfigValidation(t *testing.T) {
|
||||
cfg := ChannelMonitorV2Config{
|
||||
Platforms: []ChannelMonitorV2PlatformConfig{
|
||||
{Platform: " OpenAI ", Enabled: true, Models: []string{"gpt-5", "gpt-5", ""}},
|
||||
{Platform: "anthropic", Enabled: true},
|
||||
},
|
||||
GroupIDs: []int64{3, 1, 3},
|
||||
}
|
||||
require.NoError(t, normalizeChannelMonitorV2Config(&cfg))
|
||||
require.Equal(t, 300, cfg.RefreshIntervalSeconds)
|
||||
require.Equal(t, "anthropic", cfg.Platforms[0].Platform)
|
||||
require.Equal(t, []int64{1, 3}, cfg.GroupIDs)
|
||||
|
||||
cfg.RefreshIntervalSeconds = 120
|
||||
require.ErrorIs(t, normalizeChannelMonitorV2Config(&cfg), ErrChannelMonitorV2InvalidConfig)
|
||||
|
||||
cfg.RefreshIntervalSeconds = 60
|
||||
cfg.GroupIDs = []int64{0}
|
||||
require.ErrorIs(t, normalizeChannelMonitorV2Config(&cfg), ErrChannelMonitorV2InvalidConfig)
|
||||
}
|
||||
|
||||
func TestChannelMonitorV2ErrorTaxonomyPriority(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in ChannelMonitorV2ErrorInput
|
||||
want string
|
||||
}{
|
||||
{"cyber", ChannelMonitorV2ErrorInput{ErrorType: "cyber_policy", StatusCode: 200}, "content_policy"},
|
||||
{"auth before forbidden", ChannelMonitorV2ErrorInput{StatusCode: 403, Message: "invalid API key"}, "authentication"},
|
||||
{"context", ChannelMonitorV2ErrorInput{Message: "maximum prompt length exceeded"}, "context_limit"},
|
||||
{"unsupported", ChannelMonitorV2ErrorInput{Message: "not supported by any configured account"}, "model_unsupported"},
|
||||
{"pool", ChannelMonitorV2ErrorInput{Message: "No available accounts"}, "account_pool_unavailable"},
|
||||
{"timeout", ChannelMonitorV2ErrorInput{Message: "error code: 524"}, "timeout"},
|
||||
{"upstream", ChannelMonitorV2ErrorInput{ErrorOwner: "provider", StatusCode: 502, UpstreamStatusCode: 502}, "upstream_5xx"},
|
||||
{"other", ChannelMonitorV2ErrorInput{StatusCode: 400, Message: "unknown"}, "other"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) { require.Equal(t, tt.want, ClassifyChannelMonitorV2Error(tt.in)) })
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelMonitorV2HealthBlendsErrorTTFTAndCache(t *testing.T) {
|
||||
// error 3%/5% → 40; ttft p50 2s → 100; cache 50% → 50
|
||||
// overall = (0.6*40 + 0.2*100 + 0.2*50) / 1.0 = 54 → warning
|
||||
p50 := int64(2000)
|
||||
p95 := int64(9000)
|
||||
thresholds := ChannelMonitorV2HealthThresholds{
|
||||
MinimumSample: 20,
|
||||
WarningErrorRate: 0.02,
|
||||
CriticalErrorRate: 0.05,
|
||||
TargetTTFTMs: 2500,
|
||||
WarningTTFTMs: 2501,
|
||||
CriticalTTFTMs: 6000,
|
||||
WarningCacheRate: 0.20,
|
||||
CriticalCacheRate: 0.05,
|
||||
ErrorWeight: 0.60,
|
||||
TTFTWeight: 0.20,
|
||||
CacheWeight: 0.20,
|
||||
}
|
||||
metrics := ChannelMonitorV2Metric{
|
||||
RequestCount: 100,
|
||||
ErrorRate: 0.03,
|
||||
CacheRate: 0.50,
|
||||
CacheRateDenominator: 100,
|
||||
TTFT: ChannelMonitorV2Latency{SampleCount: 100, P50Ms: &p50, P95Ms: &p95},
|
||||
}
|
||||
health := ChannelMonitorV2HealthForWithThresholds(metrics, thresholds)
|
||||
require.Equal(t, "warning", health.ErrorRate)
|
||||
require.Equal(t, "healthy", health.TTFT)
|
||||
require.Equal(t, "healthy", health.Cache)
|
||||
require.NotNil(t, health.Score)
|
||||
require.NotNil(t, health.CacheScore)
|
||||
require.InDelta(t, 50.0, *health.CacheScore, 0.01)
|
||||
require.InDelta(t, 54.0, *health.Score, 0.01)
|
||||
require.Equal(t, "warning", health.Overall)
|
||||
|
||||
// Perfect signals → 100
|
||||
p50OK := int64(1000)
|
||||
metrics.ErrorRate = 0
|
||||
metrics.CacheRate = 1
|
||||
metrics.TTFT.P50Ms = &p50OK
|
||||
health = ChannelMonitorV2HealthForWithThresholds(metrics, thresholds)
|
||||
require.Equal(t, "healthy", health.Overall)
|
||||
require.NotNil(t, health.Score)
|
||||
require.InDelta(t, 100.0, *health.Score, 0.01)
|
||||
|
||||
// Small samples stay unknown
|
||||
health = ChannelMonitorV2HealthFor(ChannelMonitorV2Metric{RequestCount: 2})
|
||||
require.Equal(t, "unknown", health.Overall)
|
||||
require.Nil(t, health.Score)
|
||||
}
|
||||
|
||||
func TestChannelMonitorV2DefaultHealthThresholdsAreTolerant(t *testing.T) {
|
||||
p50 := int64(2500)
|
||||
health := ChannelMonitorV2HealthFor(ChannelMonitorV2Metric{
|
||||
RequestCount: 100,
|
||||
ErrorRate: 0.03,
|
||||
CacheRate: 0,
|
||||
CacheRateDenominator: 100,
|
||||
TTFT: ChannelMonitorV2Latency{SampleCount: 100, P50Ms: &p50},
|
||||
})
|
||||
require.Equal(t, "healthy", health.ErrorRate)
|
||||
require.Equal(t, "healthy", health.TTFT)
|
||||
require.Equal(t, "healthy", health.Cache)
|
||||
require.Equal(t, "healthy", health.Overall)
|
||||
require.NotNil(t, health.CacheScore)
|
||||
require.InDelta(t, 100.0, *health.CacheScore, 0.01)
|
||||
}
|
||||
|
||||
func TestErrorRateTTFTAndCacheScoreHelpers(t *testing.T) {
|
||||
require.InDelta(t, 100.0, errorRateScore(0, 0.05), 0.001)
|
||||
require.InDelta(t, 0.0, errorRateScore(0.05, 0.05), 0.001)
|
||||
require.InDelta(t, 40.0, errorRateScore(0.03, 0.05), 0.001)
|
||||
|
||||
require.InDelta(t, 100.0, ttftP50Score(2500, 2500, 6000), 0.001)
|
||||
require.InDelta(t, 100.0, ttftP50Score(1000, 2500, 6000), 0.001)
|
||||
require.InDelta(t, 0.0, ttftP50Score(6000, 2500, 6000), 0.001)
|
||||
require.InDelta(t, 50.0, ttftP50Score(4250, 2500, 6000), 0.001)
|
||||
|
||||
require.InDelta(t, 0.0, cacheRateScore(0), 0.001)
|
||||
require.InDelta(t, 100.0, cacheRateScore(1), 0.001)
|
||||
require.InDelta(t, 50.0, cacheRateScore(0.5), 0.001)
|
||||
require.Equal(t, "critical", cacheRateBand(0.02, 0.20, 0.05))
|
||||
require.Equal(t, "warning", cacheRateBand(0.10, 0.20, 0.05))
|
||||
require.Equal(t, "healthy", cacheRateBand(0.50, 0.20, 0.05))
|
||||
}
|
||||
|
||||
func TestChannelMonitorV2UsersRemovesOtherUserIdentity(t *testing.T) {
|
||||
selfID, otherID := int64(7), int64(9)
|
||||
repo := &channelMonitorV2RepoStub{config: ChannelMonitorV2Config{Enabled: true}, users: &ChannelMonitorV2List[ChannelMonitorV2UserRow]{Items: []ChannelMonitorV2UserRow{
|
||||
{UserID: &otherID, Email: "other@example.com", Username: "other"},
|
||||
{UserID: &selfID, Email: "self@example.com", Username: "self"},
|
||||
}}}
|
||||
result, err := NewChannelMonitorV2Service(repo).Users(context.Background(), ChannelMonitorV2Filter{}, selfID, false)
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, result.Items[0].UserID)
|
||||
require.Empty(t, result.Items[0].Email)
|
||||
require.Equal(t, "Other user #1", result.Items[0].DisplayLabel)
|
||||
require.Equal(t, selfID, *result.Items[1].UserID)
|
||||
require.True(t, result.Items[1].IsSelf)
|
||||
require.Equal(t, "Me", result.Items[1].DisplayLabel)
|
||||
}
|
||||
|
||||
func TestChannelMonitorV2UsersAppendsSelfWhenMissingFromRanking(t *testing.T) {
|
||||
selfID, otherID := int64(7), int64(9)
|
||||
repo := &channelMonitorV2RepoStub{config: ChannelMonitorV2Config{Enabled: true}, users: &ChannelMonitorV2List[ChannelMonitorV2UserRow]{Items: []ChannelMonitorV2UserRow{
|
||||
{UserID: &otherID, Email: "other@example.com", Username: "other", Metrics: ChannelMonitorV2Metric{RequestCount: 10}},
|
||||
}}}
|
||||
result, err := NewChannelMonitorV2Service(repo).Users(context.Background(), ChannelMonitorV2Filter{}, selfID, false)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, result.Items, 2)
|
||||
self := result.Items[1]
|
||||
require.True(t, self.IsSelf)
|
||||
require.Equal(t, "Me", self.DisplayLabel)
|
||||
require.Equal(t, selfID, *self.UserID)
|
||||
require.Equal(t, 0, self.Rank) // unranked / no traffic in window
|
||||
}
|
||||
|
||||
func TestChannelMonitorV2TopUsersKeepsSelfOutsideLimit(t *testing.T) {
|
||||
items := make([]ChannelMonitorV2UserRow, 0, 12)
|
||||
for i := 1; i <= 12; i++ {
|
||||
id := int64(i)
|
||||
items = append(items, ChannelMonitorV2UserRow{UserID: &id, Rank: i, DisplayLabel: fmt.Sprintf("u%d", i)})
|
||||
}
|
||||
// self is rank 12 (index 11)
|
||||
out := channelMonitorV2TopUsersWithSelf(items, 11, 10)
|
||||
require.Len(t, out, 11)
|
||||
require.Equal(t, int64(12), *out[10].UserID)
|
||||
require.Equal(t, 12, out[10].Rank)
|
||||
}
|
||||
|
||||
func TestChannelMonitorV2ReadAPIsRejectDisabledConfig(t *testing.T) {
|
||||
repo := &channelMonitorV2RepoStub{config: ChannelMonitorV2Config{Enabled: false}}
|
||||
_, err := NewChannelMonitorV2Service(repo).Matrix(context.Background(), ChannelMonitorV2Filter{}, ChannelMonitorV2GroupByPlatform, false)
|
||||
require.ErrorIs(t, err, ErrChannelMonitorDisabled)
|
||||
}
|
||||
|
||||
func TestNormalizeChannelMonitorV2IgnoredCategories(t *testing.T) {
|
||||
got := normalizeChannelMonitorV2IgnoredCategories([]string{"timeout", "TIMEOUT", "not-a-real", "", "timeout", "other"})
|
||||
require.Equal(t, []string{"other", "timeout"}, got)
|
||||
}
|
||||
|
||||
func TestApplyIgnoredErrorsAdjustsRateOnly(t *testing.T) {
|
||||
// applyIgnoredErrors lives in repository package; mirror the scored-rate formula.
|
||||
// scored errors = total_errors - ignored; error_rate = scored / request_count
|
||||
// success_rate stays SuccessRequests / RequestCount (ignored ≠ success).
|
||||
m := ChannelMonitorV2Metric{RequestCount: 100, ErrorRequests: 20, SuccessRequests: 80, ErrorRate: 0.2, SuccessRate: 0.8}
|
||||
ignored := int64(5)
|
||||
counted := m.ErrorRequests - ignored
|
||||
errorRate := float64(counted) / float64(m.RequestCount)
|
||||
successRate := float64(m.SuccessRequests) / float64(m.RequestCount)
|
||||
require.InDelta(t, 0.15, errorRate, 0.0001)
|
||||
require.InDelta(t, 0.80, successRate, 0.0001)
|
||||
require.Equal(t, int64(20), m.ErrorRequests)
|
||||
require.Equal(t, int64(100), m.RequestCount)
|
||||
}
|
||||
|
||||
func TestErrorsForViewerStripsDetailsAndCountsForNonAdmin(t *testing.T) {
|
||||
fixture := &ChannelMonitorV2List[ChannelMonitorV2ErrorRow]{
|
||||
Items: []ChannelMonitorV2ErrorRow{{
|
||||
Category: "timeout",
|
||||
Count: 42,
|
||||
Rate: 0.4,
|
||||
Details: []ChannelMonitorV2ErrorDetail{{
|
||||
Platform: "anthropic",
|
||||
Model: "claude",
|
||||
ErrorType: "upstream",
|
||||
StatusCode: 504,
|
||||
UpstreamStatusCode: 504,
|
||||
Message: "gateway timeout sk-secret",
|
||||
Count: 12,
|
||||
}},
|
||||
}},
|
||||
}
|
||||
repo := &channelMonitorV2RepoStub{config: ChannelMonitorV2Config{Enabled: true}, errors: fixture}
|
||||
svc := NewChannelMonitorV2Service(repo)
|
||||
|
||||
userList, err := svc.ErrorsForViewer(context.Background(), ChannelMonitorV2Filter{}, false)
|
||||
require.NoError(t, err)
|
||||
require.False(t, repo.admin)
|
||||
require.Len(t, userList.Items, 1)
|
||||
require.Zero(t, userList.Items[0].Count)
|
||||
require.Empty(t, userList.Items[0].Details)
|
||||
require.InDelta(t, 0.4, userList.Items[0].Rate, 0.0001)
|
||||
|
||||
adminList, err := svc.ErrorsForViewer(context.Background(), ChannelMonitorV2Filter{}, true)
|
||||
require.NoError(t, err)
|
||||
require.True(t, repo.admin)
|
||||
require.Equal(t, int64(42), adminList.Items[0].Count)
|
||||
require.Len(t, adminList.Items[0].Details, 1)
|
||||
require.Contains(t, adminList.Items[0].Details[0].Message, "gateway timeout")
|
||||
}
|
||||
|
||||
func TestSnapshotRedactsPublicConfigPolicyFields(t *testing.T) {
|
||||
updatedBy := int64(9)
|
||||
repo := &channelMonitorV2RepoStub{
|
||||
config: ChannelMonitorV2Config{Enabled: true},
|
||||
snap: &ChannelMonitorV2Snapshot{
|
||||
Config: ChannelMonitorV2Config{
|
||||
Version: 3,
|
||||
Enabled: true,
|
||||
RefreshIntervalSeconds: 300,
|
||||
Platforms: []ChannelMonitorV2PlatformConfig{
|
||||
{Platform: "openai", Enabled: true, Models: []string{"gpt-5"}},
|
||||
},
|
||||
GroupIDs: []int64{1, 2},
|
||||
IgnoredErrorCategories: []string{"timeout"},
|
||||
UpdatedBy: &updatedBy,
|
||||
},
|
||||
Metrics: ChannelMonitorV2Metric{RequestCount: 100, ErrorRate: 0.1, SuccessRate: 0.9, RPM: 5},
|
||||
},
|
||||
}
|
||||
svc := NewChannelMonitorV2Service(repo)
|
||||
snap, err := svc.Snapshot(context.Background(), ChannelMonitorV2Filter{}, false)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, snap.Config.GroupIDs)
|
||||
require.Empty(t, snap.Config.IgnoredErrorCategories)
|
||||
require.Nil(t, snap.Config.UpdatedBy)
|
||||
require.Empty(t, snap.Config.Platforms[0].Models)
|
||||
require.Equal(t, 300, snap.Config.RefreshIntervalSeconds)
|
||||
require.Zero(t, snap.Metrics.RequestCount)
|
||||
require.InDelta(t, 0.1, snap.Metrics.ErrorRate, 0.0001)
|
||||
require.InDelta(t, 5.0, snap.Metrics.RPM, 0.0001)
|
||||
}
|
||||
|
||||
func TestRedactChannelMonitorV2MetricKeepsRates(t *testing.T) {
|
||||
p50 := int64(100)
|
||||
m := ChannelMonitorV2Metric{
|
||||
SuccessRequests: 90, ErrorRequests: 10, RequestCount: 100,
|
||||
InputTokens: 1, OutputTokens: 2, CacheCreationTokens: 3, CacheReadTokens: 4,
|
||||
TokenCount: 10, RPM: 12, TPM: 34, ErrorRate: 0.1, SuccessRate: 0.9,
|
||||
CacheRate: 0.5, CacheRateNumerator: 4, CacheRateDenominator: 8,
|
||||
TTFT: ChannelMonitorV2Latency{SampleCount: 50, P50Ms: &p50},
|
||||
}
|
||||
upstream := int64(3)
|
||||
m.UpstreamAffectedRequests = &upstream
|
||||
redactChannelMonitorV2Metric(&m, false)
|
||||
require.Zero(t, m.RequestCount)
|
||||
require.Zero(t, m.ErrorRequests)
|
||||
require.Zero(t, m.TokenCount)
|
||||
require.Zero(t, m.CacheRateNumerator)
|
||||
require.Zero(t, m.TTFT.SampleCount)
|
||||
require.Nil(t, m.UpstreamAffectedRequests)
|
||||
require.InDelta(t, 0.1, m.ErrorRate, 0.0001)
|
||||
require.InDelta(t, 12.0, m.RPM, 0.0001)
|
||||
require.InDelta(t, 34.0, m.TPM, 0.0001)
|
||||
require.NotNil(t, m.TTFT.P50Ms)
|
||||
require.Equal(t, int64(100), *m.TTFT.P50Ms)
|
||||
|
||||
redactChannelMonitorV2Metric(&m, true)
|
||||
require.Zero(t, m.RPM)
|
||||
require.Zero(t, m.TPM)
|
||||
require.InDelta(t, 0.1, m.ErrorRate, 0.0001)
|
||||
}
|
||||
|
||||
func TestChannelMonitorV2HealthTTFTAtTargetIsHealthy(t *testing.T) {
|
||||
p50 := int64(2500)
|
||||
m := ChannelMonitorV2Metric{
|
||||
RequestCount: 100, ErrorRate: 0,
|
||||
CacheRate: 1, CacheRateDenominator: 100,
|
||||
TTFT: ChannelMonitorV2Latency{SampleCount: 100, P50Ms: &p50},
|
||||
}
|
||||
h := ChannelMonitorV2HealthFor(m)
|
||||
require.Equal(t, "healthy", h.TTFT)
|
||||
require.NotNil(t, h.TTFTScore)
|
||||
require.InDelta(t, 100.0, *h.TTFTScore, 0.01)
|
||||
}
|
||||
Reference in New Issue
Block a user