diff --git a/Makefile b/Makefile index a2a49abb2..b0bc1b99f 100644 --- a/Makefile +++ b/Makefile @@ -3,12 +3,17 @@ FRONTEND_CRITICAL_VITEST := \ src/api/__tests__/client.spec.ts \ src/api/__tests__/tokenRefresh.spec.ts \ + src/api/__tests__/channelMonitorV2.spec.ts \ src/views/auth/__tests__/LinuxDoCallbackView.spec.ts \ src/views/auth/__tests__/WechatCallbackView.spec.ts \ src/views/user/__tests__/PaymentView.spec.ts \ src/views/user/__tests__/PaymentResultView.spec.ts \ + src/views/user/__tests__/ChannelStatusView.mode.spec.ts \ src/components/user/profile/__tests__/ProfileInfoCard.spec.ts \ - src/views/admin/__tests__/SettingsView.spec.ts + src/views/admin/__tests__/SettingsView.spec.ts \ + src/features/channel-monitor-v2/__tests__/designSystem.structure.spec.ts \ + src/features/channel-monitor-v2/__tests__/monitorFormat.spec.ts \ + src/features/channel-monitor-v2/__tests__/monitorZoom.spec.ts # 一键编译前后端 build: build-backend build-frontend diff --git a/backend/cmd/server/wire.go b/backend/cmd/server/wire.go index 2e4b0d902..8bd9ac5b1 100644 --- a/backend/cmd/server/wire.go +++ b/backend/cmd/server/wire.go @@ -110,6 +110,7 @@ func provideCleanup( backupSvc *service.BackupService, paymentOrderExpiry *service.PaymentOrderExpiryService, channelMonitorRunner *service.ChannelMonitorRunner, + channelMonitorV2Aggregator *service.ChannelMonitorV2Aggregator, quotaFlusher *service.UserPlatformQuotaUsageFlusher, upstreamBillingProbe *service.UpstreamBillingProbeService, ollamaCloudUsage *service.OllamaCloudUsageService, @@ -319,7 +320,13 @@ func provideCleanup( } return nil }}, - {"ChannelMonitorRunner", func() error { + {"ChannelMonitorV2Aggregator", func() error { + if channelMonitorV2Aggregator != nil { + channelMonitorV2Aggregator.Stop() + } + return nil + }}, + {"ChannelMonitorRunner", func() error { if channelMonitorRunner != nil { channelMonitorRunner.Stop() } diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index 3017a36bb..8969bf441 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -174,8 +174,11 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { announcementService := service.NewAnnouncementService(announcementRepository, announcementReadRepository, userRepository, userSubscriptionRepository) announcementHandler := handler.NewAnnouncementHandler(announcementService) channelMonitorRepository := repository.NewChannelMonitorRepository(client, db) - channelMonitorService := service.ProvideChannelMonitorService(channelMonitorRepository, secretEncryptor) + channelMonitorService := service.ProvideChannelMonitorService(channelMonitorRepository, secretEncryptor, settingService) channelMonitorUserHandler := handler.NewChannelMonitorUserHandler(channelMonitorService, settingService) + channelMonitorV2Repository := repository.NewChannelMonitorV2Repository(db) + channelMonitorV2Service := service.ProvideChannelMonitorV2Service(channelMonitorV2Repository, settingService) + channelMonitorV2Handler := handler.NewChannelMonitorV2Handler(channelMonitorV2Service) dashboardAggregationRepository := repository.NewDashboardAggregationRepository(db) dashboardStatsCache := repository.NewDashboardCache(redisClient, configConfig) dashboardService := service.NewDashboardService(usageLogRepository, dashboardAggregationRepository, dashboardStatsCache, configConfig) @@ -308,7 +311,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { batchImageHandler := handler.ProvideBatchImageHandler(batchImagePublicService, batchImageDownloadService, batchImageCleanupService, openAIGatewayHandler) idempotencyCoordinator := service.ProvideIdempotencyCoordinator(idempotencyRepository, configConfig) idempotencyCleanupService := service.ProvideIdempotencyCleanupService(idempotencyRepository, configConfig) - handlers := handler.ProvideHandlers(authHandler, userHandler, apiKeyHandler, usageHandler, redeemHandler, subscriptionHandler, announcementHandler, channelMonitorUserHandler, adminHandlers, gatewayHandler, openAIGatewayHandler, handlerSettingHandler, totpHandler, passkeyHandler, handlerPaymentHandler, paymentWebhookHandler, availableChannelHandler, modelPlazaHandler, asyncImageHandler, batchImageHandler, idempotencyCoordinator, idempotencyCleanupService) + handlers := handler.ProvideHandlers(authHandler, userHandler, apiKeyHandler, usageHandler, redeemHandler, subscriptionHandler, announcementHandler, channelMonitorUserHandler, channelMonitorV2Handler, adminHandlers, gatewayHandler, openAIGatewayHandler, handlerSettingHandler, totpHandler, passkeyHandler, handlerPaymentHandler, paymentWebhookHandler, availableChannelHandler, modelPlazaHandler, asyncImageHandler, batchImageHandler, idempotencyCoordinator, idempotencyCleanupService) jwtAuthMiddleware := middleware.NewJWTAuthMiddleware(authService, userService, settingService, auditLogService) optionalJWTAuthMiddleware := middleware.NewOptionalJWTAuthMiddleware(authService, userService, settingService, auditLogService) adminAuthMiddleware := middleware.NewAdminAuthMiddleware(authService, userService, settingService, auditLogService) @@ -331,8 +334,9 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { scheduledTestRunnerService := service.ProvideScheduledTestRunnerService(scheduledTestPlanRepository, scheduledTestService, accountTestService, rateLimitService, configConfig) paymentOrderExpiryService := service.ProvidePaymentOrderExpiryService(paymentService, leaderLockCache, db) channelMonitorRunner := service.ProvideChannelMonitorRunner(channelMonitorService, settingService) + channelMonitorV2Aggregator := service.ProvideChannelMonitorV2Aggregator(channelMonitorV2Repository, db, settingService) userPlatformQuotaUsageFlusher := service.ProvideUserPlatformQuotaUsageFlusher(configConfig, billingCache, serviceUserPlatformQuotaRepository, timingWheelService) - v := provideCleanup(client, redisClient, opsMetricsCollector, opsAggregationService, opsAlertEvaluatorService, opsCleanupService, opsScheduledReportService, opsSystemLogSink, opsService, opsIngressRejectAggregator, apiKeyService, authCacheInvalidationWorker, schedulerSnapshotService, tokenRefreshService, accountExpiryService, openAICodexVersionSyncService, proxyExpiryService, subscriptionExpiryService, usageCleanupService, idempotencyCleanupService, batchImageCleanupService, batchImageWorkerRuntime, pricingService, emailQueueService, billingCacheService, usageRecordWorkerPool, subscriptionService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, openAIGatewayService, scheduledTestRunnerService, backupService, paymentOrderExpiryService, channelMonitorRunner, userPlatformQuotaUsageFlusher, upstreamBillingProbeService, ollamaCloudUsageService, auditLogService, promptService) + v := provideCleanup(client, redisClient, opsMetricsCollector, opsAggregationService, opsAlertEvaluatorService, opsCleanupService, opsScheduledReportService, opsSystemLogSink, opsService, opsIngressRejectAggregator, apiKeyService, authCacheInvalidationWorker, schedulerSnapshotService, tokenRefreshService, accountExpiryService, openAICodexVersionSyncService, proxyExpiryService, subscriptionExpiryService, usageCleanupService, idempotencyCleanupService, batchImageCleanupService, batchImageWorkerRuntime, pricingService, emailQueueService, billingCacheService, usageRecordWorkerPool, subscriptionService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, openAIGatewayService, scheduledTestRunnerService, backupService, paymentOrderExpiryService, channelMonitorRunner, channelMonitorV2Aggregator, userPlatformQuotaUsageFlusher, upstreamBillingProbeService, ollamaCloudUsageService, auditLogService, promptService) application := &Application{ Server: httpServer, PromptAudit: promptService, @@ -398,6 +402,7 @@ func provideCleanup( backupSvc *service.BackupService, paymentOrderExpiry *service.PaymentOrderExpiryService, channelMonitorRunner *service.ChannelMonitorRunner, + channelMonitorV2Aggregator *service.ChannelMonitorV2Aggregator, quotaFlusher *service.UserPlatformQuotaUsageFlusher, upstreamBillingProbe *service.UpstreamBillingProbeService, ollamaCloudUsage *service.OllamaCloudUsageService, @@ -606,6 +611,12 @@ func provideCleanup( } return nil }}, + {"ChannelMonitorV2Aggregator", func() error { + if channelMonitorV2Aggregator != nil { + channelMonitorV2Aggregator.Stop() + } + return nil + }}, {"ChannelMonitorRunner", func() error { if channelMonitorRunner != nil { channelMonitorRunner.Stop() diff --git a/backend/cmd/server/wire_gen_test.go b/backend/cmd/server/wire_gen_test.go index 805ba5353..dff3a9129 100644 --- a/backend/cmd/server/wire_gen_test.go +++ b/backend/cmd/server/wire_gen_test.go @@ -88,6 +88,7 @@ func TestProvideCleanup_WithMinimalDependencies_NoPanic(t *testing.T) { nil, // backupSvc nil, // paymentOrderExpiry nil, // channelMonitorRunner + nil, // channelMonitorV2Aggregator nil, // quotaFlusher nil, // upstreamBillingProbe nil, // ollamaCloudUsage diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index bb5ce72f8..2c12398ba 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -370,7 +370,9 @@ func (h *SettingHandler) GetSettings(c *gin.Context) { PaymentAlipayMobilePrecreateDeepLink: paymentCfg.AlipayMobilePrecreateDeepLink, ChannelMonitorEnabled: settings.ChannelMonitorEnabled, + ChannelMonitorMode: settings.ChannelMonitorMode, ChannelMonitorDefaultIntervalSeconds: settings.ChannelMonitorDefaultIntervalSeconds, + ChannelMonitorHideThroughput: settings.ChannelMonitorHideThroughput, GrokDefaultTextModel: settings.GrokDefaultTextModel, GrokCrossClientModelMapEnabled: settings.GrokCrossClientModelMapEnabled, diff --git a/backend/internal/handler/admin/setting_handler_update.go b/backend/internal/handler/admin/setting_handler_update.go index d962196f3..37971eb82 100644 --- a/backend/internal/handler/admin/setting_handler_update.go +++ b/backend/internal/handler/admin/setting_handler_update.go @@ -327,8 +327,10 @@ type UpdateSettingsRequest struct { PaymentAlipayMobilePrecreateDeepLink *bool `json:"payment_alipay_mobile_precreate_deep_link"` // Channel Monitor feature switch - ChannelMonitorEnabled *bool `json:"channel_monitor_enabled"` - ChannelMonitorDefaultIntervalSeconds *int `json:"channel_monitor_default_interval_seconds"` + ChannelMonitorEnabled *bool `json:"channel_monitor_enabled"` + ChannelMonitorMode *string `json:"channel_monitor_mode"` + ChannelMonitorDefaultIntervalSeconds *int `json:"channel_monitor_default_interval_seconds"` + ChannelMonitorHideThroughput *bool `json:"channel_monitor_hide_throughput"` // Grok model mapping policy GrokDefaultTextModel *string `json:"grok_default_text_model"` @@ -1863,12 +1865,24 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { } return previousSettings.ChannelMonitorEnabled }(), + ChannelMonitorMode: func() string { + if req.ChannelMonitorMode != nil { + return *req.ChannelMonitorMode + } + return previousSettings.ChannelMonitorMode + }(), ChannelMonitorDefaultIntervalSeconds: func() int { if req.ChannelMonitorDefaultIntervalSeconds != nil { return *req.ChannelMonitorDefaultIntervalSeconds } return previousSettings.ChannelMonitorDefaultIntervalSeconds }(), + ChannelMonitorHideThroughput: func() bool { + if req.ChannelMonitorHideThroughput != nil { + return *req.ChannelMonitorHideThroughput + } + return previousSettings.ChannelMonitorHideThroughput + }(), GrokDefaultTextModel: func() string { if req.GrokDefaultTextModel != nil { return *req.GrokDefaultTextModel @@ -2318,7 +2332,9 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { PaymentAlipayMobilePrecreateDeepLink: updatedPaymentCfg.AlipayMobilePrecreateDeepLink, ChannelMonitorEnabled: updatedSettings.ChannelMonitorEnabled, + ChannelMonitorMode: updatedSettings.ChannelMonitorMode, ChannelMonitorDefaultIntervalSeconds: updatedSettings.ChannelMonitorDefaultIntervalSeconds, + ChannelMonitorHideThroughput: updatedSettings.ChannelMonitorHideThroughput, GrokDefaultTextModel: updatedSettings.GrokDefaultTextModel, GrokCrossClientModelMapEnabled: updatedSettings.GrokCrossClientModelMapEnabled, diff --git a/backend/internal/handler/channel_monitor_user_handler.go b/backend/internal/handler/channel_monitor_user_handler.go index cc36b3346..7e42c5a74 100644 --- a/backend/internal/handler/channel_monitor_user_handler.go +++ b/backend/internal/handler/channel_monitor_user_handler.go @@ -35,7 +35,8 @@ func (h *ChannelMonitorUserHandler) featureEnabled(c *gin.Context) bool { if h.settingService == nil { return true } - return h.settingService.GetChannelMonitorRuntime(c.Request.Context()).Enabled + runtime := h.settingService.GetChannelMonitorRuntime(c.Request.Context()) + return runtime.Enabled && runtime.Mode == service.ChannelMonitorModeV1 } // --- Response --- diff --git a/backend/internal/handler/channel_monitor_v2_handler.go b/backend/internal/handler/channel_monitor_v2_handler.go new file mode 100644 index 000000000..ea661d1a7 --- /dev/null +++ b/backend/internal/handler/channel_monitor_v2_handler.go @@ -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 +} diff --git a/backend/internal/handler/channel_monitor_v2_handler_test.go b/backend/internal/handler/channel_monitor_v2_handler_test.go new file mode 100644 index 000000000..b61c582f4 --- /dev/null +++ b/backend/internal/handler/channel_monitor_v2_handler_test.go @@ -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) +} diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go index 8b1dab3f4..6e67cd2ac 100644 --- a/backend/internal/handler/dto/settings.go +++ b/backend/internal/handler/dto/settings.go @@ -300,8 +300,10 @@ type SystemSettings struct { AccountQuotaNotifyEmails []NotifyEmailEntry `json:"account_quota_notify_emails"` // Channel Monitor feature switch - ChannelMonitorEnabled bool `json:"channel_monitor_enabled"` - ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"` + ChannelMonitorEnabled bool `json:"channel_monitor_enabled"` + ChannelMonitorMode string `json:"channel_monitor_mode"` + ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"` + ChannelMonitorHideThroughput bool `json:"channel_monitor_hide_throughput"` // Grok model mapping policy (admin settings; empty account mapping falls back to these). GrokDefaultTextModel string `json:"grok_default_text_model"` @@ -406,8 +408,10 @@ type PublicSettings struct { BalanceLowNotifyThreshold float64 `json:"balance_low_notify_threshold"` BalanceLowNotifyRechargeURL string `json:"balance_low_notify_recharge_url"` - ChannelMonitorEnabled bool `json:"channel_monitor_enabled"` - ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"` + ChannelMonitorEnabled bool `json:"channel_monitor_enabled"` + ChannelMonitorMode string `json:"channel_monitor_mode"` + ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"` + ChannelMonitorHideThroughput bool `json:"channel_monitor_hide_throughput"` AvailableChannelsEnabled bool `json:"available_channels_enabled"` diff --git a/backend/internal/handler/handler.go b/backend/internal/handler/handler.go index 9334ffe26..359aff007 100644 --- a/backend/internal/handler/handler.go +++ b/backend/internal/handler/handler.go @@ -53,6 +53,7 @@ type Handlers struct { Subscription *SubscriptionHandler Announcement *AnnouncementHandler ChannelMonitor *ChannelMonitorUserHandler + ChannelMonitorV2 *ChannelMonitorV2Handler Admin *AdminHandlers Gateway *GatewayHandler OpenAIGateway *OpenAIGatewayHandler diff --git a/backend/internal/handler/setting_handler.go b/backend/internal/handler/setting_handler.go index 991d9beed..3886f82c2 100644 --- a/backend/internal/handler/setting_handler.go +++ b/backend/internal/handler/setting_handler.go @@ -103,7 +103,9 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) { BalanceLowNotifyRechargeURL: settings.BalanceLowNotifyRechargeURL, ChannelMonitorEnabled: settings.ChannelMonitorEnabled, + ChannelMonitorMode: settings.ChannelMonitorMode, ChannelMonitorDefaultIntervalSeconds: settings.ChannelMonitorDefaultIntervalSeconds, + ChannelMonitorHideThroughput: settings.ChannelMonitorHideThroughput, AvailableChannelsEnabled: settings.AvailableChannelsEnabled, diff --git a/backend/internal/handler/wire.go b/backend/internal/handler/wire.go index 73b36dc0c..36fdb8528 100644 --- a/backend/internal/handler/wire.go +++ b/backend/internal/handler/wire.go @@ -175,6 +175,7 @@ func ProvideHandlers( subscriptionHandler *SubscriptionHandler, announcementHandler *AnnouncementHandler, channelMonitorUserHandler *ChannelMonitorUserHandler, + channelMonitorV2Handler *ChannelMonitorV2Handler, adminHandlers *AdminHandlers, gatewayHandler *GatewayHandler, openaiGatewayHandler *OpenAIGatewayHandler, @@ -199,6 +200,7 @@ func ProvideHandlers( Subscription: subscriptionHandler, Announcement: announcementHandler, ChannelMonitor: channelMonitorUserHandler, + ChannelMonitorV2: channelMonitorV2Handler, Admin: adminHandlers, Gateway: gatewayHandler, OpenAIGateway: openaiGatewayHandler, @@ -225,6 +227,7 @@ var ProviderSet = wire.NewSet( NewSubscriptionHandler, NewAnnouncementHandler, NewChannelMonitorUserHandler, + NewChannelMonitorV2Handler, ProvideGatewayHandler, ProvideOpenAIGatewayHandler, NewTotpHandler, diff --git a/backend/internal/repository/channel_monitor_v2_aggregation.go b/backend/internal/repository/channel_monitor_v2_aggregation.go new file mode 100644 index 000000000..6b91a8132 --- /dev/null +++ b/backend/internal/repository/channel_monitor_v2_aggregation.go @@ -0,0 +1,454 @@ +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(` + usageLogEffectivePlatformExpr + `)` +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 { + // During historical bootstrap, retain all 1m facts until the cursor reaches + // the oldest rollup boundary. Otherwise adjacent chunks would rebuild the + // same daily bucket from source rows already pruned by the prior chunk. + var backfillCursor time.Time + if err := tx.QueryRowContext(ctx, `SELECT backfill_cursor FROM channel_monitor_v2_watermarks WHERE id = 1`).Scan(&backfillCursor); err == nil && backfillCursor.After(channelMonitorV2RetentionCutoff(now, channelMonitorV2RetentionMax)) { + return nil + } + 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) AND ` + usageLogSuccessFilterUL + `), + COALESCE(SUM(ul.input_tokens) FILTER (WHERE ` + usageLogSuccessFilterUL + `), 0), + COALESCE(SUM(ul.output_tokens) FILTER (WHERE ` + usageLogSuccessFilterUL + `), 0), + COALESCE(SUM(ul.cache_creation_tokens) FILTER (WHERE ` + usageLogSuccessFilterUL + `), 0), + COALESCE(SUM(ul.cache_read_tokens) FILTER (WHERE ` + usageLogSuccessFilterUL + `), 0), + COALESCE(SUM(ul.first_token_ms) FILTER (WHERE ul.first_token_ms IS NOT NULL AND ` + usageLogSuccessFilterUL + `), 0), + COUNT(ul.first_token_ms) FILTER (WHERE ` + usageLogSuccessFilterUL + `), + COALESCE(SUM(ul.duration_ms) FILTER (WHERE ul.duration_ms IS NOT NULL AND ` + usageLogSuccessFilterUL + `), 0), + COUNT(ul.duration_ms) FILTER (WHERE ` + usageLogSuccessFilterUL + `), 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) AND ` + usageLogSuccessFilterUL + `), + COALESCE(SUM(ul.input_tokens) FILTER (WHERE ` + usageLogSuccessFilterUL + `), 0), + COALESCE(SUM(ul.output_tokens) FILTER (WHERE ` + usageLogSuccessFilterUL + `), 0), + COALESCE(SUM(ul.cache_creation_tokens) FILTER (WHERE ` + usageLogSuccessFilterUL + `), 0), + COALESCE(SUM(ul.cache_read_tokens) FILTER (WHERE ` + usageLogSuccessFilterUL + `), 0), + COALESCE(SUM(ul.first_token_ms) FILTER (WHERE ul.first_token_ms IS NOT NULL AND ` + usageLogSuccessFilterUL + `), 0), + COUNT(ul.first_token_ms) FILTER (WHERE ` + usageLogSuccessFilterUL + `), + COALESCE(SUM(ul.duration_ms) FILTER (WHERE ul.duration_ms IS NOT NULL AND ` + usageLogSuccessFilterUL + `), 0), + COUNT(ul.duration_ms) FILTER (WHERE ` + usageLogSuccessFilterUL + `), 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 + AND ` + usageLogSuccessFilterUL + ` +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` +} + +// Error dedup lookback: request_id branch is bounded by chunk start minus 90 +// minutes so candidate_ids never forces a full-history scan of ops_error_logs. +const channelMonitorV2ErrorAggregationSQL = ` +WITH dedup AS ( + WITH candidate_ids AS MATERIALIZED ( + SELECT DISTINCT request_id + FROM ops_error_logs + WHERE created_at >= $1 AND created_at < $2 AND NULLIF(request_id, '') IS NOT NULL + ) + 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 ( + (NULLIF(current_error.request_id, '') IS NULL AND current_error.created_at >= $1 AND current_error.created_at < $2) + OR ( + current_error.request_id IN (SELECT request_id FROM candidate_ids) + AND current_error.created_at >= $1 - INTERVAL '90 minutes' + 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') + 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 + WHERE bucket_start >= $1 AND bucket_start < $2 +), 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. +// +// Coverage starts track how far back recompute has walked ($1 = chunk start), not +// "min(source_log.created_at)". Using global min(ops_error_logs) pins +// error_coverage_start to the first real error forever and collapses UI windows +// when errors only exist in a recent slice (common on first upgrade). +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, + $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 { + // Coarse buckets are immutable between boundaries during the normal + // trailing refresh. Historical backfills and boundary-crossing windows + // still rebuild them; this avoids repeatedly regrouping the full current + // day/user table every few minutes. + if seconds >= 43200 && sameFixedRollupBucket(start, end, seconds) { + continue + } + 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 +} + +func sameFixedRollupBucket(start, end time.Time, seconds int) bool { + if !end.After(start) { + return true + } + interval := time.Duration(seconds) * time.Second + return start.Truncate(interval).Equal(end.Add(-time.Nanosecond).Truncate(interval)) +} + +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` diff --git a/backend/internal/repository/channel_monitor_v2_repo.go b/backend/internal/repository/channel_monitor_v2_repo.go new file mode 100644 index 000000000..c9ad5031f --- /dev/null +++ b/backend/internal/repository/channel_monitor_v2_repo.go @@ -0,0 +1,1553 @@ +package repository + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "sort" + "strings" + "time" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/lib/pq" +) + +type channelMonitorV2Repository struct{ db *sql.DB } + +func NewChannelMonitorV2Repository(db *sql.DB) service.ChannelMonitorV2Repository { + return &channelMonitorV2Repository{db: db} +} + +func (r *channelMonitorV2Repository) GetConfig(ctx context.Context) (*service.ChannelMonitorV2Config, error) { + var cfg service.ChannelMonitorV2Config + var platforms, thresholds []byte + err := r.db.QueryRowContext(ctx, ` + SELECT version, enabled, refresh_interval_seconds, platforms, group_ids, + COALESCE(ignored_error_categories, '{}'), + COALESCE(health_thresholds, '{}'::jsonb), + updated_at, updated_by + FROM channel_monitor_v2_config WHERE id = 1`).Scan( + &cfg.Version, &cfg.Enabled, &cfg.RefreshIntervalSeconds, &platforms, + pq.Array(&cfg.GroupIDs), pq.Array(&cfg.IgnoredErrorCategories), + &thresholds, + &cfg.UpdatedAt, &cfg.UpdatedBy, + ) + if err != nil { + return nil, fmt.Errorf("get channel monitor v2 config: %w", err) + } + if err := json.Unmarshal(platforms, &cfg.Platforms); err != nil { + return nil, fmt.Errorf("decode channel monitor v2 platforms: %w", err) + } + if cfg.IgnoredErrorCategories == nil { + cfg.IgnoredErrorCategories = []string{} + } + cfg.HealthThresholds = service.DefaultChannelMonitorV2HealthThresholds() + if len(thresholds) > 0 { + _ = json.Unmarshal(thresholds, &cfg.HealthThresholds) + } + cfg.HealthThresholds = service.NormalizeChannelMonitorV2HealthThresholds(cfg.HealthThresholds) + return &cfg, nil +} + +func (r *channelMonitorV2Repository) UpdateConfig(ctx context.Context, cfg service.ChannelMonitorV2Config, expectedVersion int) (*service.ChannelMonitorV2Config, error) { + platforms, err := json.Marshal(cfg.Platforms) + if err != nil { + return nil, err + } + if cfg.IgnoredErrorCategories == nil { + cfg.IgnoredErrorCategories = []string{} + } + cfg.HealthThresholds = service.NormalizeChannelMonitorV2HealthThresholds(cfg.HealthThresholds) + thresholds, err := json.Marshal(cfg.HealthThresholds) + if err != nil { + return nil, err + } + var updated service.ChannelMonitorV2Config + var raw, rawThresholds []byte + err = r.db.QueryRowContext(ctx, ` + UPDATE channel_monitor_v2_config + SET version = version + 1, enabled = $1, refresh_interval_seconds = $2, + platforms = $3, group_ids = $4, ignored_error_categories = $5, + health_thresholds = $6, updated_by = $7, updated_at = NOW() + WHERE id = 1 AND version = $8 + RETURNING version, enabled, refresh_interval_seconds, platforms, group_ids, + COALESCE(ignored_error_categories, '{}'), + COALESCE(health_thresholds, '{}'::jsonb), + updated_at, updated_by`, + cfg.Enabled, cfg.RefreshIntervalSeconds, platforms, pq.Array(cfg.GroupIDs), + pq.Array(cfg.IgnoredErrorCategories), thresholds, cfg.UpdatedBy, expectedVersion, + ).Scan(&updated.Version, &updated.Enabled, &updated.RefreshIntervalSeconds, &raw, + pq.Array(&updated.GroupIDs), pq.Array(&updated.IgnoredErrorCategories), + &rawThresholds, + &updated.UpdatedAt, &updated.UpdatedBy) + if err == sql.ErrNoRows { + return nil, service.ErrChannelMonitorV2ConfigConflict + } + if err != nil { + return nil, fmt.Errorf("update channel monitor v2 config: %w", err) + } + if err := json.Unmarshal(raw, &updated.Platforms); err != nil { + return nil, err + } + if updated.IgnoredErrorCategories == nil { + updated.IgnoredErrorCategories = []string{} + } + updated.HealthThresholds = service.DefaultChannelMonitorV2HealthThresholds() + _ = json.Unmarshal(rawThresholds, &updated.HealthThresholds) + updated.HealthThresholds = service.NormalizeChannelMonitorV2HealthThresholds(updated.HealthThresholds) + return &updated, nil +} + +type channelMonitorV2Fact struct { + BucketStart, Platform, GroupName, Model string + GroupID int64 + Success, Errors, UpstreamAffected, UpstreamAttempts int64 + Input, Output, CacheCreation, CacheRead int64 + TTFTSum, TTFTCount, DurationSum, DurationCount int64 +} + +type channelMonitorV2Histogram struct { + BucketStart, Platform, Model, Metric string + GroupID, UserID int64 + UpperBound int64 + Count int64 +} + +func (r *channelMonitorV2Repository) GetDimensions(ctx context.Context, filter service.ChannelMonitorV2Filter, cfg service.ChannelMonitorV2Config) (*service.ChannelMonitorV2Dimensions, error) { + coverage, err := r.loadCoverage(ctx, filter) + if err != nil { + return nil, err + } + // Catalog dimensions must stay independent of the currently selected + // platform/group/model multi-select filters so pickers keep their full option set. + // Time/coverage still come from the request filter; config scope (enabled platforms, + // group allow-list, display-model collapse) still applies. + catalogFilter := channelMonitorV2CatalogFilter(channelMonitorV2CommonCoverageFilter(filter, *coverage)) + where, args, _ := channelMonitorV2WhereWithRollup(catalogFilter, cfg, "m") + query := `SELECT m.platform, COALESCE(g.name, ''), lower(COALESCE(NULLIF(TRIM(g.platform), ''), 'unknown')), m.group_id, m.model, + SUM(m.success_requests + m.error_requests) + FROM ` + channelMonitorV2MetricsTable(catalogFilter) + ` m + LEFT JOIN groups g ON g.id = NULLIF(m.group_id, 0) ` + where + ` + GROUP BY m.platform, g.name, g.platform, m.group_id, m.model` + rows, err := r.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + platformCounts := map[string]int64{} + type modelValue struct { + platform string + count int64 + } + // Model dimensions are platform-scoped; identical names can represent + // different upstream models and must not share counts. + modelCounts := map[string]modelValue{} + type groupValue struct { + name string + platform string + count int64 + } + groupCounts := map[int64]groupValue{} + for _, platform := range channelMonitorV2EnabledPlatforms(cfg) { + platformCounts[platform] += 0 + for _, p := range cfg.Platforms { + if p.Platform != platform || len(p.Models) == 0 { + continue + } + for _, model := range p.Models { + modelCounts[platform+"\x00"+model] = modelValue{platform: platform, count: 0} + } + } + } + groupInfo, err := r.loadChannelMonitorV2GroupInfo(ctx, configuredChannelMonitorV2GroupIDs(catalogFilter, cfg)) + if err != nil { + return nil, err + } + for groupID, info := range groupInfo { + groupCounts[groupID] = groupValue{name: info.name, platform: info.platform, count: 0} + } + for rows.Next() { + var platform, groupName, groupPlatform, model string + var groupID, count int64 + if err := rows.Scan(&platform, &groupName, &groupPlatform, &groupID, &model, &count); err != nil { + return nil, err + } + platformCounts[platform] += count + displayModel := channelMonitorV2DisplayModel(cfg, platform, model) + modelKey := platform + "\x00" + displayModel + currentModel := modelCounts[modelKey] + currentModel.count += count + if currentModel.platform == "" { + currentModel.platform = platform + } + modelCounts[modelKey] = currentModel + if groupID > 0 { + current := groupCounts[groupID] + current.name = groupName + current.platform = groupPlatform + current.count += count + groupCounts[groupID] = current + } + } + result := &service.ChannelMonitorV2Dimensions{ + Platforms: []service.ChannelMonitorV2Dimension{}, + Groups: []service.ChannelMonitorV2GroupDimension{}, + Models: []service.ChannelMonitorV2Dimension{}, + } + for value, count := range platformCounts { + result.Platforms = append(result.Platforms, service.ChannelMonitorV2Dimension{Value: value, Label: value, RequestCount: count}) + } + for value, meta := range modelCounts { + result.Models = append(result.Models, service.ChannelMonitorV2Dimension{Value: value, Label: channelMonitorV2ModelLabel(value), Platform: meta.platform, RequestCount: meta.count}) + } + for id, value := range groupCounts { + result.Groups = append(result.Groups, service.ChannelMonitorV2GroupDimension{ID: id, Name: value.name, Platform: value.platform, RequestCount: value.count}) + } + sort.Slice(result.Platforms, func(i, j int) bool { return result.Platforms[i].RequestCount > result.Platforms[j].RequestCount }) + sort.Slice(result.Models, func(i, j int) bool { return result.Models[i].RequestCount > result.Models[j].RequestCount }) + sort.Slice(result.Groups, func(i, j int) bool { return result.Groups[i].RequestCount > result.Groups[j].RequestCount }) + return result, rows.Err() +} + +func (r *channelMonitorV2Repository) GetSnapshot(ctx context.Context, filter service.ChannelMonitorV2Filter, cfg service.ChannelMonitorV2Config, admin bool) (*service.ChannelMonitorV2Snapshot, error) { + coverage, err := r.loadCoverage(ctx, filter) + if err != nil { + return nil, err + } + effectiveFilter := channelMonitorV2CommonCoverageFilter(filter, *coverage) + facts, err := r.loadFacts(ctx, effectiveFilter, cfg, true) + if err != nil { + return nil, err + } + histograms, err := r.loadHistograms(ctx, effectiveFilter, cfg, 0, true) + if err != nil { + return nil, err + } + byBucket := map[string]*metricAccumulator{} + total := newMetricAccumulator() + for _, fact := range facts { + if !channelMonitorV2ModelSelected(filter, cfg, fact.Platform, fact.Model) { + continue + } + bucket := fact.BucketStart + acc := byBucket[bucket] + if acc == nil { + acc = newMetricAccumulator() + byBucket[bucket] = acc + } + acc.addFact(fact) + total.addFact(fact) + } + for _, hist := range histograms { + if !channelMonitorV2ModelSelected(filter, cfg, hist.Platform, hist.Model) { + continue + } + if acc := byBucket[hist.BucketStart]; acc != nil { + acc.addHistogram(hist) + } + total.addHistogram(hist) + } + minutes := channelMonitorV2CoveredMinutes(filter, *coverage) + // Category-level ignored errors adjust error_rate without dropping volume. + ignoredByBucket, ignoredTotal, err := r.loadIgnoredErrorCounts(ctx, effectiveFilter, cfg) + if err != nil { + return nil, fmt.Errorf("load ignored error counts: %w", err) + } + metrics := total.metric(minutes, admin) + applyIgnoredErrors(&metrics, ignoredTotal) + if !admin { + cfg.UpdatedBy = nil + } + result := &service.ChannelMonitorV2Snapshot{Config: cfg, Coverage: *coverage, Metrics: metrics, Health: service.ChannelMonitorV2HealthForWithThresholds(metrics, cfg.HealthThresholds), Trend: []service.ChannelMonitorV2TrendPoint{}} + keys := make([]string, 0, len(byBucket)) + for key := range byBucket { + keys = append(keys, key) + } + sort.Strings(keys) + for _, key := range keys { + bucket, _ := time.Parse(time.RFC3339Nano, key) + m := byBucket[key].metric(filter.Bucket.Minutes(), admin) + applyIgnoredErrors(&m, ignoredByBucket[key]) + result.Trend = append(result.Trend, service.ChannelMonitorV2TrendPoint{BucketStart: bucket, Metrics: m, Health: service.ChannelMonitorV2HealthForWithThresholds(m, cfg.HealthThresholds)}) + } + return result, nil +} + +func (r *channelMonitorV2Repository) GetModels(ctx context.Context, filter service.ChannelMonitorV2Filter, cfg service.ChannelMonitorV2Config, admin bool) (*service.ChannelMonitorV2List[service.ChannelMonitorV2ModelRow], error) { + coverage, err := r.loadCoverage(ctx, filter) + if err != nil { + return nil, err + } + effectiveFilter := channelMonitorV2CommonCoverageFilter(filter, *coverage) + facts, err := r.loadFacts(ctx, effectiveFilter, cfg, false) + if err != nil { + return nil, err + } + hist, err := r.loadHistograms(ctx, effectiveFilter, cfg, 0, false) + if err != nil { + return nil, err + } + accs := map[string]*metricAccumulator{} + for _, platform := range channelMonitorV2EnabledPlatforms(cfg) { + if len(filter.Platforms) > 0 && !containsString(filter.Platforms, platform) { + continue + } + models := configuredChannelMonitorV2Models(cfg, platform, filter) + for _, model := range models { + if model == "" { + continue + } + key := platform + "\x00" + model + if accs[key] == nil { + accs[key] = newMetricAccumulator() + } + } + } + for _, fact := range facts { + if !channelMonitorV2ModelSelected(filter, cfg, fact.Platform, fact.Model) { + continue + } + model := channelMonitorV2DisplayModel(cfg, fact.Platform, fact.Model) + key := fact.Platform + "\x00" + model + if accs[key] == nil { + accs[key] = newMetricAccumulator() + } + accs[key].addFact(fact) + } + for _, h := range hist { + if !channelMonitorV2ModelSelected(filter, cfg, h.Platform, h.Model) { + continue + } + key := h.Platform + "\x00" + channelMonitorV2DisplayModel(cfg, h.Platform, h.Model) + if accs[key] != nil { + accs[key].addHistogram(h) + } + } + // Per platform+model ignored counts for error_rate adjustment. + ignoredByPM, _, err := r.loadIgnoredErrorCountsByPlatformModel(ctx, effectiveFilter, cfg) + if err != nil { + return nil, fmt.Errorf("load ignored error counts by platform/model: %w", err) + } + items := make([]service.ChannelMonitorV2ModelRow, 0, len(accs)) + minutes := channelMonitorV2CoveredMinutes(filter, *coverage) + for key, acc := range accs { + parts := strings.SplitN(key, "\x00", 2) + metrics := acc.metric(minutes, admin) + applyIgnoredErrors(&metrics, ignoredByPM[key]) + items = append(items, service.ChannelMonitorV2ModelRow{Platform: parts[0], Model: parts[1], Metrics: metrics, Health: service.ChannelMonitorV2HealthForWithThresholds(metrics, cfg.HealthThresholds)}) + } + sort.Slice(items, func(i, j int) bool { return items[i].Metrics.RequestCount > items[j].Metrics.RequestCount }) + return &service.ChannelMonitorV2List[service.ChannelMonitorV2ModelRow]{Coverage: *coverage, Items: items}, nil +} + +type channelMonitorV2MatrixKey struct { + platform, model string + groupID int64 +} + +type channelMonitorV2MatrixAccumulator struct { + groupName string + total *metricAccumulator + buckets map[string]*metricAccumulator +} + +func (r *channelMonitorV2Repository) GetMatrix(ctx context.Context, filter service.ChannelMonitorV2Filter, cfg service.ChannelMonitorV2Config, groupBy service.ChannelMonitorV2GroupBy, admin bool) (*service.ChannelMonitorV2Matrix, error) { + coverage, err := r.loadCoverage(ctx, filter) + if err != nil { + return nil, err + } + effectiveFilter := channelMonitorV2CommonCoverageFilter(filter, *coverage) + facts, err := r.loadFacts(ctx, effectiveFilter, cfg, true) + if err != nil { + return nil, err + } + histograms, err := r.loadHistograms(ctx, effectiveFilter, cfg, 0, true) + if err != nil { + return nil, err + } + + seedGroupIDs := configuredChannelMonitorV2GroupIDs(filter, cfg) + // Empty config group list means all groups — load active groups so matrix seed + // can materialize real platform/group rows (not bare platform placeholders). + if len(seedGroupIDs) == 0 && + (groupBy == service.ChannelMonitorV2GroupByPlatformGroup || groupBy == service.ChannelMonitorV2GroupByPlatformGroupModel) { + allIDs, loadErr := r.listActiveGroupIDs(ctx) + if loadErr != nil { + return nil, loadErr + } + seedGroupIDs = allIDs + } + groupInfo, err := r.loadChannelMonitorV2GroupInfo(ctx, seedGroupIDs) + if err != nil { + return nil, err + } + accs := seedChannelMonitorV2MatrixAccumulators(filter, cfg, groupBy, groupInfo) + for _, fact := range facts { + if !channelMonitorV2ModelSelected(filter, cfg, fact.Platform, fact.Model) { + continue + } + // platform_group dimensions require a real group; skip group_id=0 traffic rows. + if (groupBy == service.ChannelMonitorV2GroupByPlatformGroup || groupBy == service.ChannelMonitorV2GroupByPlatformGroupModel) && + fact.GroupID <= 0 { + continue + } + key := channelMonitorV2MatrixDimensionKey(groupBy, cfg, fact.Platform, fact.GroupID, fact.Model) + acc := accs[key] + if acc == nil { + acc = &channelMonitorV2MatrixAccumulator{total: newMetricAccumulator(), buckets: make(map[string]*metricAccumulator)} + accs[key] = acc + } + if key.groupID > 0 { + acc.groupName = fact.GroupName + } + bucket := acc.buckets[fact.BucketStart] + if bucket == nil { + bucket = newMetricAccumulator() + acc.buckets[fact.BucketStart] = bucket + } + acc.total.addFact(fact) + bucket.addFact(fact) + } + for _, histogram := range histograms { + if !channelMonitorV2ModelSelected(filter, cfg, histogram.Platform, histogram.Model) { + continue + } + if (groupBy == service.ChannelMonitorV2GroupByPlatformGroup || groupBy == service.ChannelMonitorV2GroupByPlatformGroupModel) && + histogram.GroupID <= 0 { + continue + } + key := channelMonitorV2MatrixDimensionKey(groupBy, cfg, histogram.Platform, histogram.GroupID, histogram.Model) + acc := accs[key] + if acc == nil { + continue + } + acc.total.addHistogram(histogram) + if bucket := acc.buckets[histogram.BucketStart]; bucket != nil { + bucket.addHistogram(histogram) + } + } + + result := &service.ChannelMonitorV2Matrix{GroupBy: groupBy, Coverage: *coverage, Items: make([]service.ChannelMonitorV2MatrixRow, 0, len(accs))} + minutes := channelMonitorV2CoveredMinutes(filter, *coverage) + ignoredByDimBucket, ignoredByDim, err := r.loadIgnoredErrorCountsByMatrixKey(ctx, effectiveFilter, cfg, groupBy) + if err != nil { + return nil, fmt.Errorf("load ignored error counts by matrix key: %w", err) + } + for key, acc := range accs { + // platform_group views only emit rows with a real group_id. + if (groupBy == service.ChannelMonitorV2GroupByPlatformGroup || groupBy == service.ChannelMonitorV2GroupByPlatformGroupModel) && + key.groupID <= 0 { + continue + } + metrics := acc.total.metric(minutes, admin) + applyIgnoredErrors(&metrics, ignoredByDim[key]) + row := service.ChannelMonitorV2MatrixRow{Platform: key.platform, GroupName: acc.groupName, Model: key.model, Metrics: metrics, Health: service.ChannelMonitorV2HealthForWithThresholds(metrics, cfg.HealthThresholds), Buckets: []service.ChannelMonitorV2TrendPoint{}} + if key.groupID > 0 { + groupID := key.groupID + row.GroupID = &groupID + } + bucketKeys := make([]string, 0, len(acc.buckets)) + for bucket := range acc.buckets { + bucketKeys = append(bucketKeys, bucket) + } + sort.Strings(bucketKeys) + for _, bucketKey := range bucketKeys { + bucketStart, parseErr := time.Parse(time.RFC3339Nano, bucketKey) + if parseErr != nil { + continue + } + bucketMetrics := acc.buckets[bucketKey].metric(filter.Bucket.Minutes(), admin) + applyIgnoredErrors(&bucketMetrics, ignoredByDimBucket[key][bucketKey]) + row.Buckets = append(row.Buckets, service.ChannelMonitorV2TrendPoint{BucketStart: bucketStart, Metrics: bucketMetrics, Health: service.ChannelMonitorV2HealthForWithThresholds(bucketMetrics, cfg.HealthThresholds)}) + } + result.Items = append(result.Items, row) + } + sort.Slice(result.Items, func(i, j int) bool { + a, b := result.Items[i], result.Items[j] + if a.Platform != b.Platform { + return a.Platform < b.Platform + } + if a.GroupName != b.GroupName { + return a.GroupName < b.GroupName + } + if a.GroupID != nil && b.GroupID != nil && *a.GroupID != *b.GroupID { + return *a.GroupID < *b.GroupID + } + return a.Model < b.Model + }) + return result, nil +} + +func channelMonitorV2MatrixDimensionKey(groupBy service.ChannelMonitorV2GroupBy, cfg service.ChannelMonitorV2Config, platform string, groupID int64, model string) channelMonitorV2MatrixKey { + key := channelMonitorV2MatrixKey{platform: platform} + if groupBy == service.ChannelMonitorV2GroupByPlatformGroup || groupBy == service.ChannelMonitorV2GroupByPlatformGroupModel { + key.groupID = groupID + } + if groupBy == service.ChannelMonitorV2GroupByPlatformModel || groupBy == service.ChannelMonitorV2GroupByPlatformGroupModel { + key.model = channelMonitorV2DisplayModel(cfg, platform, model) + } + return key +} + +func seedChannelMonitorV2MatrixAccumulators(filter service.ChannelMonitorV2Filter, cfg service.ChannelMonitorV2Config, groupBy service.ChannelMonitorV2GroupBy, groupInfo map[int64]channelMonitorV2GroupInfo) map[channelMonitorV2MatrixKey]*channelMonitorV2MatrixAccumulator { + accs := map[channelMonitorV2MatrixKey]*channelMonitorV2MatrixAccumulator{} + platforms := channelMonitorV2EnabledPlatforms(cfg) + if len(filter.Platforms) > 0 { + platforms = intersectStrings(platforms, filter.Platforms) + } + // Platform-only groupBy seeds groupID=0. When group is part of the dimension + // (platform_group / platform_group_model), never seed a bare platform row — + // only real group IDs (from config or discovered groupInfo). + needsGroup := groupBy == service.ChannelMonitorV2GroupByPlatformGroup || groupBy == service.ChannelMonitorV2GroupByPlatformGroupModel + groupIDs := []int64{0} + if needsGroup { + groupIDs = configuredChannelMonitorV2GroupIDs(filter, cfg) + if len(groupIDs) == 0 { + // Empty config group list means "all groups": use discovered active groups. + for id := range groupInfo { + groupIDs = append(groupIDs, id) + } + sort.Slice(groupIDs, func(i, j int) bool { return groupIDs[i] < groupIDs[j] }) + } + // Still empty → seed nothing; rows come only from fact traffic. + if len(groupIDs) == 0 { + return accs + } + } + for _, platform := range platforms { + models := []string{""} + if groupBy == service.ChannelMonitorV2GroupByPlatformModel || groupBy == service.ChannelMonitorV2GroupByPlatformGroupModel { + models = configuredChannelMonitorV2Models(cfg, platform, filter) + if len(models) == 0 { + models = []string{""} + } + } + for _, groupID := range groupIDs { + info := groupInfo[groupID] + if needsGroup { + if groupID <= 0 { + continue + } + if info.platform == "" || info.platform != platform { + continue + } + } + for _, model := range models { + key := channelMonitorV2MatrixKey{platform: platform} + if needsGroup { + key.groupID = groupID + } + if groupBy == service.ChannelMonitorV2GroupByPlatformModel || groupBy == service.ChannelMonitorV2GroupByPlatformGroupModel { + key.model = model + } + if accs[key] == nil { + accs[key] = &channelMonitorV2MatrixAccumulator{groupName: info.name, total: newMetricAccumulator(), buckets: make(map[string]*metricAccumulator)} + } + } + } + } + return accs +} + +func configuredChannelMonitorV2Models(cfg service.ChannelMonitorV2Config, platform string, filter service.ChannelMonitorV2Filter) []string { + models := []string{} + for _, p := range cfg.Platforms { + if p.Platform != platform { + continue + } + models = append(models, p.Models...) + break + } + if len(filter.Models) > 0 { + if len(models) == 0 { + models = append(models, filter.Models...) + } else { + models = intersectStrings(models, filter.Models) + } + } + return models +} + +// channelMonitorV2CatalogFilter clears multi-select dimensions so catalog endpoints +// return the full configured option set for the time window. Metrics queries must +// keep using the original filter. +func channelMonitorV2CatalogFilter(filter service.ChannelMonitorV2Filter) service.ChannelMonitorV2Filter { + catalog := filter + catalog.Platforms = nil + catalog.GroupIDs = nil + catalog.Models = nil + return catalog +} + +func configuredChannelMonitorV2GroupIDs(filter service.ChannelMonitorV2Filter, cfg service.ChannelMonitorV2Config) []int64 { + groups := append([]int64(nil), cfg.GroupIDs...) + if len(filter.GroupIDs) > 0 { + if len(groups) > 0 { + groups = intersectInt64(groups, filter.GroupIDs) + } else { + groups = append([]int64(nil), filter.GroupIDs...) + } + } + return groups +} + +type channelMonitorV2GroupInfo struct { + name string + platform string +} + +func (r *channelMonitorV2Repository) listActiveGroupIDs(ctx context.Context) ([]int64, error) { + rows, err := r.db.QueryContext(ctx, `SELECT id FROM groups WHERE deleted_at IS NULL AND status = 'active' ORDER BY id`) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + ids := []int64{} + for rows.Next() { + var id int64 + if err := rows.Scan(&id); err != nil { + return nil, err + } + ids = append(ids, id) + } + return ids, rows.Err() +} + +func (r *channelMonitorV2Repository) loadChannelMonitorV2GroupInfo(ctx context.Context, groupIDs []int64) (map[int64]channelMonitorV2GroupInfo, error) { + out := map[int64]channelMonitorV2GroupInfo{} + if len(groupIDs) == 0 { + return out, nil + } + rows, err := r.db.QueryContext(ctx, `SELECT id, COALESCE(name, ''), lower(COALESCE(NULLIF(TRIM(platform), ''), 'unknown')) FROM groups WHERE id = ANY($1) AND deleted_at IS NULL AND status = 'active'`, pq.Array(groupIDs)) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + for rows.Next() { + var id int64 + var name, platform string + if err := rows.Scan(&id, &name, &platform); err != nil { + return nil, err + } + out[id] = channelMonitorV2GroupInfo{name: name, platform: platform} + } + return out, rows.Err() +} + +func (r *channelMonitorV2Repository) GetErrors(ctx context.Context, filter service.ChannelMonitorV2Filter, cfg service.ChannelMonitorV2Config, includeAdmin bool) (*service.ChannelMonitorV2List[service.ChannelMonitorV2ErrorRow], error) { + coverage, err := r.loadCoverage(ctx, filter) + if err != nil { + return nil, err + } + filter = channelMonitorV2CommonCoverageFilter(filter, *coverage) + where, args, _ := channelMonitorV2WhereWithRollup(filter, cfg, "e") + rows, err := r.db.QueryContext(ctx, `SELECT e.platform,e.model,e.error_category,SUM(e.error_requests) FROM `+channelMonitorV2ErrorMetricsTable(filter)+` e `+where+` AND e.taxonomy_version = `+fmt.Sprint(service.ChannelMonitorV2TaxonomyVersion)+` GROUP BY e.platform,e.model,e.error_category`, args...) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + counts := map[string]int64{} + var total int64 + ignoredSet := service.ChannelMonitorV2IgnoredCategorySet(cfg) + for rows.Next() { + var platform, model, category string + var count int64 + if err := rows.Scan(&platform, &model, &category, &count); err != nil { + return nil, err + } + if !channelMonitorV2ModelSelected(filter, cfg, platform, model) { + continue + } + if category == "" { + category = "other" + } + counts[category] += count + total += count + } + items := make([]service.ChannelMonitorV2ErrorRow, 0, len(counts)) + // Upstream message samples are admin-only: skip the raw ops_error_logs scan + // for user-facing callers (privacy + avoids multi-million-row online scans). + var details map[string][]service.ChannelMonitorV2ErrorDetail + if includeAdmin { + loaded, detailErr := r.loadErrorDetails(ctx, filter, cfg) + if detailErr != nil { + return nil, detailErr + } + details = loaded + } + for category, count := range counts { + rate := 0.0 + if total > 0 { + rate = float64(count) / float64(total) + } + _, ignored := ignoredSet[category] + row := service.ChannelMonitorV2ErrorRow{Category: category, Count: count, Rate: rate, Ignored: ignored} + if includeAdmin { + row.Details = details[category] + } + items = append(items, row) + } + // Stable: counted categories first (by count), then ignored (grey in UI). + sort.Slice(items, func(i, j int) bool { + if items[i].Ignored != items[j].Ignored { + return !items[i].Ignored && items[j].Ignored + } + return items[i].Count > items[j].Count + }) + return &service.ChannelMonitorV2List[service.ChannelMonitorV2ErrorRow]{Coverage: *coverage, Items: items}, rows.Err() +} + +func (r *channelMonitorV2Repository) loadErrorDetails(ctx context.Context, filter service.ChannelMonitorV2Filter, cfg service.ChannelMonitorV2Config) (map[string][]service.ChannelMonitorV2ErrorDetail, error) { + conditions := []string{ + "current_error.created_at >= $1", + "current_error.created_at < $2", + "NOT current_error.is_count_tokens", + "(COALESCE(current_error.status_code, 0) >= 400 OR current_error.error_type = 'cyber_policy')", + `(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, newer.id) > (current_error.created_at, current_error.id) + ))`, + } + args := []any{filter.Start, filter.End} + platforms := channelMonitorV2EnabledPlatforms(cfg) + if len(filter.Platforms) > 0 { + platforms = intersectStrings(platforms, filter.Platforms) + } + if len(platforms) > 0 { + args = append(args, pq.Array(platforms)) + conditions = append(conditions, fmt.Sprintf("lower(COALESCE(NULLIF(TRIM(current_error.platform), ''), 'unknown')) = ANY($%d)", len(args))) + } else { + conditions = append(conditions, "FALSE") + } + groups := cfg.GroupIDs + groupScopeEmpty := false + if len(filter.GroupIDs) > 0 { + if len(groups) > 0 { + groups = intersectInt64(groups, filter.GroupIDs) + groupScopeEmpty = len(groups) == 0 + } else { + groups = filter.GroupIDs + } + } + if groupScopeEmpty { + conditions = append(conditions, "FALSE") + } else if len(groups) > 0 { + args = append(args, pq.Array(groups)) + conditions = append(conditions, fmt.Sprintf("COALESCE(current_error.group_id, 0) = ANY($%d)", len(args))) + } + query := `SELECT + lower(COALESCE(NULLIF(TRIM(current_error.platform), ''), 'unknown')) AS platform, + COALESCE(current_error.group_id, 0) AS group_id, + COALESCE(NULLIF(TRIM(current_error.requested_model), ''), NULLIF(TRIM(current_error.model), ''), 'unknown') AS model, + COALESCE(current_error.error_type, '') AS error_type, + COALESCE(current_error.error_owner, '') AS error_owner, + COALESCE(current_error.error_source, '') AS error_source, + COALESCE(current_error.status_code, 0) AS status_code, + COALESCE(current_error.upstream_status_code, 0) AS upstream_status_code, + LEFT(COALESCE(NULLIF(current_error.upstream_error_message, ''), NULLIF(current_error.error_message, ''), NULLIF(current_error.upstream_error_detail, ''), NULLIF(current_error.error_body, ''), current_error.error_type, ''), 600) AS message, + COUNT(*) AS count + FROM ops_error_logs current_error + WHERE ` + strings.Join(conditions, " AND ") + ` + GROUP BY 1,2,3,4,5,6,7,8,9 + ORDER BY count DESC + LIMIT 400` + rows, err := r.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + out := map[string][]service.ChannelMonitorV2ErrorDetail{} + for rows.Next() { + var platform, model, errorType, owner, source, message string + var groupID int64 + var statusCode, upstreamStatusCode int + var count int64 + if err := rows.Scan(&platform, &groupID, &model, &errorType, &owner, &source, &statusCode, &upstreamStatusCode, &message, &count); err != nil { + return nil, err + } + if !channelMonitorV2ModelSelected(filter, cfg, platform, model) { + continue + } + category := service.ClassifyChannelMonitorV2Error(service.ChannelMonitorV2ErrorInput{ + ErrorType: errorType, + ErrorOwner: owner, + ErrorSource: source, + StatusCode: statusCode, + UpstreamStatusCode: upstreamStatusCode, + Message: message, + }) + if len(out[category]) >= 5 { + continue + } + out[category] = append(out[category], service.ChannelMonitorV2ErrorDetail{ + Platform: platform, + Model: channelMonitorV2DisplayModel(cfg, platform, model), + ErrorType: errorType, + StatusCode: statusCode, + UpstreamStatusCode: upstreamStatusCode, + Message: sanitizeChannelMonitorV2ErrorDetail(message), + Count: count, + }) + } + return out, rows.Err() +} + +func sanitizeChannelMonitorV2ErrorDetail(message string) string { + message = strings.Join(strings.Fields(message), " ") + replacements := []string{"Bearer ", "bearer ", "sk-", "sk-proj-"} + for _, marker := range replacements { + if idx := strings.Index(message, marker); idx >= 0 { + end := idx + len(marker) + for end < len(message) && message[end] != ' ' && message[end] != ',' && message[end] != '"' { + end++ + } + message = message[:idx] + marker + "****" + message[end:] + } + } + if len(message) > 240 { + message = message[:240] + "…" + } + return message +} + +func (r *channelMonitorV2Repository) GetUsers(ctx context.Context, filter service.ChannelMonitorV2Filter, cfg service.ChannelMonitorV2Config, admin bool) (*service.ChannelMonitorV2List[service.ChannelMonitorV2UserRow], error) { + coverage, err := r.loadCoverage(ctx, filter) + if err != nil { + return nil, err + } + effectiveFilter := channelMonitorV2CommonCoverageFilter(filter, *coverage) + filter = effectiveFilter + where, args, _ := channelMonitorV2WhereWithRollup(filter, cfg, "m") + query := `SELECT m.user_id,COALESCE(u.email,''),COALESCE(u.username,''),m.platform,m.model, + SUM(m.success_requests),SUM(m.error_requests),SUM(m.input_tokens),SUM(m.output_tokens),SUM(m.cache_creation_tokens),SUM(m.cache_read_tokens),SUM(m.ttft_sum_ms),SUM(m.ttft_count),SUM(m.duration_sum_ms),SUM(m.duration_count) + FROM ` + channelMonitorV2UserMetricsTable(filter) + ` m LEFT JOIN users u ON u.id=m.user_id ` + where + ` GROUP BY m.user_id,u.email,u.username,m.platform,m.model` + rows, err := r.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + type userMeta struct{ email, username string } + meta := map[int64]userMeta{} + accs := map[int64]*metricAccumulator{} + for rows.Next() { + var uid int64 + var email, username string + var f channelMonitorV2Fact + if err := rows.Scan(&uid, &email, &username, &f.Platform, &f.Model, &f.Success, &f.Errors, &f.Input, &f.Output, &f.CacheCreation, &f.CacheRead, &f.TTFTSum, &f.TTFTCount, &f.DurationSum, &f.DurationCount); err != nil { + return nil, err + } + if !channelMonitorV2ModelSelected(filter, cfg, f.Platform, f.Model) { + continue + } + if accs[uid] == nil { + accs[uid] = newMetricAccumulator() + } + accs[uid].addFact(f) + meta[uid] = userMeta{email, username} + } + histograms, err := r.loadHistograms(ctx, filter, cfg, -1, false) + if err != nil { + return nil, err + } + for _, histogram := range histograms { + if !channelMonitorV2ModelSelected(filter, cfg, histogram.Platform, histogram.Model) { + continue + } + if acc := accs[histogram.UserID]; acc != nil { + acc.addHistogram(histogram) + } + } + // Error category facts are not per-user. Approximate ignored-error impact by + // applying the window-level ignored/error ratio to each user's error count so + // user-rank rates stay consistent with overview scoring. + _, ignoredTotal, err := r.loadIgnoredErrorCounts(ctx, effectiveFilter, cfg) + if err != nil { + return nil, fmt.Errorf("load ignored error counts for users: %w", err) + } + var windowErrors int64 + for _, acc := range accs { + windowErrors += acc.errors + } + ignoreRatio := 0.0 + if windowErrors > 0 && ignoredTotal > 0 { + ignoreRatio = float64(ignoredTotal) / float64(windowErrors) + if ignoreRatio > 1 { + ignoreRatio = 1 + } + } + + items := make([]service.ChannelMonitorV2UserRow, 0, len(accs)) + minutes := channelMonitorV2CoveredMinutes(filter, *coverage) + for uid, acc := range accs { + id := uid + m := meta[uid] + label := m.username + if label == "" { + label = m.email + } + metrics := acc.metric(minutes, false) + if ignoreRatio > 0 && metrics.ErrorRequests > 0 { + approxIgnored := int64(float64(metrics.ErrorRequests)*ignoreRatio + 0.5) + applyIgnoredErrors(&metrics, approxIgnored) + } + // Recompute health after rate adjustment. + // (metric() does not attach health; callers that need it recompute.) + items = append(items, service.ChannelMonitorV2UserRow{UserID: &id, Email: m.email, Username: m.username, DisplayLabel: label, CanDrilldown: admin, Metrics: metrics}) + } + sort.Slice(items, func(i, j int) bool { return items[i].Metrics.RequestCount > items[j].Metrics.RequestCount }) + return &service.ChannelMonitorV2List[service.ChannelMonitorV2UserRow]{Coverage: *coverage, Items: items}, rows.Err() +} + +func (r *channelMonitorV2Repository) loadFacts(ctx context.Context, filter service.ChannelMonitorV2Filter, cfg service.ChannelMonitorV2Config, byBucket bool) ([]channelMonitorV2Fact, error) { + where, args, bucketSeconds := channelMonitorV2WhereWithRollup(filter, cfg, "m") + bucketExpr := "MIN(m.bucket_start)" + group := "m.platform,m.group_id,g.name,m.model" + if byBucket { + if bucketSeconds > 0 { + bucketExpr = "m.bucket_start" + group = bucketExpr + "," + group + } else { + args = append([]any{fmt.Sprintf("%d seconds", int(filter.Bucket.Seconds()))}, args...) + where = shiftSQLPlaceholders(where, 1) + bucketExpr = "date_bin($1::interval,m.bucket_start,TIMESTAMPTZ '1970-01-01')" + group = bucketExpr + "," + group + } + } + query := `SELECT ` + bucketExpr + `,m.platform,m.group_id,COALESCE(g.name,''),m.model,SUM(m.success_requests),SUM(m.error_requests),SUM(m.upstream_affected_requests),SUM(m.upstream_attempt_count),SUM(m.input_tokens),SUM(m.output_tokens),SUM(m.cache_creation_tokens),SUM(m.cache_read_tokens),SUM(m.ttft_sum_ms),SUM(m.ttft_count),SUM(m.duration_sum_ms),SUM(m.duration_count) FROM ` + channelMonitorV2MetricsTable(filter) + ` m LEFT JOIN groups g ON g.id=NULLIF(m.group_id,0) ` + where + ` GROUP BY ` + group + rows, err := r.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + facts := []channelMonitorV2Fact{} + for rows.Next() { + var bucket time.Time + var f channelMonitorV2Fact + if err := rows.Scan(&bucket, &f.Platform, &f.GroupID, &f.GroupName, &f.Model, &f.Success, &f.Errors, &f.UpstreamAffected, &f.UpstreamAttempts, &f.Input, &f.Output, &f.CacheCreation, &f.CacheRead, &f.TTFTSum, &f.TTFTCount, &f.DurationSum, &f.DurationCount); err != nil { + return nil, err + } + f.BucketStart = bucket.UTC().Format(time.RFC3339Nano) + facts = append(facts, f) + } + return facts, rows.Err() +} + +func (r *channelMonitorV2Repository) loadHistograms(ctx context.Context, filter service.ChannelMonitorV2Filter, cfg service.ChannelMonitorV2Config, userID int64, byBucket bool) ([]channelMonitorV2Histogram, error) { + where, args, bucketSeconds := channelMonitorV2WhereWithRollup(filter, cfg, "h") + if userID < 0 { + where += " AND h.user_id > 0" + } else { + args = append(args, userID) + where += fmt.Sprintf(" AND h.user_id = $%d", len(args)) + } + bucketExpr := "MIN(h.bucket_start)" + group := "h.platform,h.group_id,h.model,h.user_id,h.metric,h.upper_bound_ms" + if byBucket { + if bucketSeconds > 0 { + bucketExpr = "h.bucket_start" + group = bucketExpr + "," + group + } else { + oldArgs := args + args = []any{fmt.Sprintf("%d seconds", int(filter.Bucket.Seconds()))} + args = append(args, oldArgs...) + where = shiftSQLPlaceholders(where, 1) + bucketExpr = "date_bin($1::interval,h.bucket_start,TIMESTAMPTZ '1970-01-01')" + group = bucketExpr + "," + group + } + } + rows, err := r.db.QueryContext(ctx, `SELECT `+bucketExpr+`,h.platform,h.group_id,h.model,h.user_id,h.metric,h.upper_bound_ms,SUM(h.sample_count) FROM `+channelMonitorV2HistogramTable(filter)+` h `+where+` GROUP BY `+group, args...) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + out := []channelMonitorV2Histogram{} + for rows.Next() { + var bucket time.Time + var h channelMonitorV2Histogram + if err := rows.Scan(&bucket, &h.Platform, &h.GroupID, &h.Model, &h.UserID, &h.Metric, &h.UpperBound, &h.Count); err != nil { + return nil, err + } + h.BucketStart = bucket.UTC().Format(time.RFC3339Nano) + out = append(out, h) + } + return out, rows.Err() +} + +func (r *channelMonitorV2Repository) GetAggregationWatermark(ctx context.Context) (*service.ChannelMonitorV2AggregationWatermark, error) { + var usageStart, errorStart, dataThrough, computed, backfill sql.NullTime + err := r.db.QueryRowContext(ctx, ` + SELECT usage_coverage_start, error_coverage_start, data_through, last_successful_at, backfill_cursor + FROM channel_monitor_v2_watermarks WHERE id = 1`).Scan(&usageStart, &errorStart, &dataThrough, &computed, &backfill) + if err == sql.ErrNoRows { + return &service.ChannelMonitorV2AggregationWatermark{}, nil + } + if err != nil { + return nil, err + } + out := &service.ChannelMonitorV2AggregationWatermark{HasData: dataThrough.Valid} + if usageStart.Valid { + out.UsageCoverageStart = usageStart.Time.UTC() + } + if errorStart.Valid { + out.ErrorCoverageStart = errorStart.Time.UTC() + } + if dataThrough.Valid { + out.DataThrough = dataThrough.Time.UTC() + } + if computed.Valid { + out.LastSuccessfulAt = computed.Time.UTC() + } + if backfill.Valid { + out.BackfillCursor = backfill.Time.UTC() + } + return out, nil +} + +func (r *channelMonitorV2Repository) loadCoverage(ctx context.Context, filter service.ChannelMonitorV2Filter) (*service.ChannelMonitorV2Coverage, error) { + wm, err := r.GetAggregationWatermark(ctx) + if err != nil { + return nil, err + } + if wm == nil { + wm = &service.ChannelMonitorV2AggregationWatermark{} + } + bootstrap := service.ChannelMonitorV2BootstrapProgress(time.Now().UTC(), wm.BackfillCursor, wm.HasData) + if !wm.HasData || wm.DataThrough.IsZero() || wm.LastSuccessfulAt.IsZero() { + return &service.ChannelMonitorV2Coverage{ + RequestedStart: filter.Start, + RequestedEnd: filter.End, + CoverageStart: filter.End, + DataThrough: filter.Start, + ComputedAt: time.Time{}, + AggregationLagSeconds: 0, + CoverageComplete: false, + BucketSeconds: int(filter.Bucket.Seconds()), + Bootstrap: bootstrap, + }, nil + } + coverageStart := filter.Start + if !wm.UsageCoverageStart.IsZero() && wm.UsageCoverageStart.After(coverageStart) { + coverageStart = wm.UsageCoverageStart + } + if !wm.ErrorCoverageStart.IsZero() && wm.ErrorCoverageStart.After(coverageStart) { + coverageStart = wm.ErrorCoverageStart + } + through := filter.End + if !wm.DataThrough.IsZero() && wm.DataThrough.Before(through) { + through = wm.DataThrough + } + computedAt := wm.LastSuccessfulAt + lag := int64(0) + if !through.IsZero() { + lag = int64(time.Since(through).Seconds()) + if lag < 0 { + lag = 0 + } + } + // Complete = history depth for this range is filled (backfill reached + // filter.Start). Do not require data_through >= filter.End: ParseFilter + // aligns End to the next whole bucket (often in the future), so a healthy + // minute-level lag would otherwise always show "partial historical coverage". + return &service.ChannelMonitorV2Coverage{ + RequestedStart: filter.Start, + RequestedEnd: filter.End, + CoverageStart: coverageStart, + DataThrough: through, + ComputedAt: computedAt, + AggregationLagSeconds: lag, + CoverageComplete: channelMonitorV2HistoryCoverageComplete(coverageStart, filter.Start), + BucketSeconds: int(filter.Bucket.Seconds()), + Bootstrap: bootstrap, + }, nil +} + +// channelMonitorV2HistoryCoverageComplete is true when aggregated history +// reaches the requested window start. Trailing freshness is reported via +// data_through / aggregation_lag_seconds, not coverage_complete. +func channelMonitorV2HistoryCoverageComplete(coverageStart, filterStart time.Time) bool { + if coverageStart.IsZero() || filterStart.IsZero() { + return false + } + return !coverageStart.After(filterStart) +} + +func channelMonitorV2FixedBucketSeconds(filter service.ChannelMonitorV2Filter) int { + seconds := int(filter.Bucket.Seconds()) + switch seconds { + case 300, 3600, 43200, 86400: + return seconds + default: + return 0 + } +} + +func channelMonitorV2MetricsTable(filter service.ChannelMonitorV2Filter) string { + if channelMonitorV2FixedBucketSeconds(filter) > 0 { + return "channel_monitor_v2_metrics_rollup" + } + return "channel_monitor_v2_metrics_1m" +} + +func channelMonitorV2UserMetricsTable(filter service.ChannelMonitorV2Filter) string { + if channelMonitorV2FixedBucketSeconds(filter) > 0 { + return "channel_monitor_v2_user_metrics_rollup" + } + return "channel_monitor_v2_user_metrics_1m" +} + +func channelMonitorV2ErrorMetricsTable(filter service.ChannelMonitorV2Filter) string { + if channelMonitorV2FixedBucketSeconds(filter) > 0 { + return "channel_monitor_v2_error_metrics_rollup" + } + return "channel_monitor_v2_error_metrics_1m" +} + +func channelMonitorV2HistogramTable(filter service.ChannelMonitorV2Filter) string { + if channelMonitorV2FixedBucketSeconds(filter) > 0 { + return "channel_monitor_v2_latency_histograms_rollup" + } + return "channel_monitor_v2_latency_histograms_1m" +} + +func channelMonitorV2WhereWithRollup(filter service.ChannelMonitorV2Filter, cfg service.ChannelMonitorV2Config, alias string) (string, []any, int) { + where, args := channelMonitorV2Where(filter, cfg, alias) + bucketSeconds := channelMonitorV2FixedBucketSeconds(filter) + if bucketSeconds > 0 { + args = append(args, bucketSeconds) + where += fmt.Sprintf(" AND %s.bucket_seconds = $%d", alias, len(args)) + } + return where, args, bucketSeconds +} + +func channelMonitorV2Where(filter service.ChannelMonitorV2Filter, cfg service.ChannelMonitorV2Config, alias string) (string, []any) { + conditions := []string{alias + ".bucket_start >= $1", alias + ".bucket_start < $2"} + args := []any{filter.Start, filter.End} + platforms := channelMonitorV2EnabledPlatforms(cfg) + if len(filter.Platforms) > 0 { + platforms = intersectStrings(platforms, filter.Platforms) + } + if len(platforms) > 0 { + args = append(args, pq.Array(platforms)) + conditions = append(conditions, fmt.Sprintf("%s.platform = ANY($%d)", alias, len(args))) + } else { + conditions = append(conditions, "FALSE") + } + groups := cfg.GroupIDs + groupScopeEmpty := false + if len(filter.GroupIDs) > 0 { + if len(groups) > 0 { + groups = intersectInt64(groups, filter.GroupIDs) + groupScopeEmpty = len(groups) == 0 + } else { + groups = filter.GroupIDs + } + } + if groupScopeEmpty { + conditions = append(conditions, "FALSE") + } else if len(groups) > 0 { + args = append(args, pq.Array(groups)) + conditions = append(conditions, fmt.Sprintf("%s.group_id = ANY($%d)", alias, len(args))) + } + return "WHERE " + strings.Join(conditions, " AND "), args +} + +func channelMonitorV2EnabledPlatforms(cfg service.ChannelMonitorV2Config) []string { + out := []string{} + for _, p := range cfg.Platforms { + if p.Enabled { + out = append(out, p.Platform) + } + } + return out +} + +// channelMonitorV2DisplayModel maps a raw model name for presentation. +// Semantics (parallel to empty group_ids = all groups): +// - platform not in config / disabled → keep raw model (still collected) +// - models list empty → show the real model name (no collapsing) +// - models list non-empty → selected keep identity; everything else → __other__ +func channelMonitorV2DisplayModel(cfg service.ChannelMonitorV2Config, platform, model string) string { + model = strings.TrimSpace(model) + if model == "" { + return service.ChannelMonitorV2OtherModel + } + for _, p := range cfg.Platforms { + if p.Platform != platform { + continue + } + // Empty allow-list: surface every real model instead of dumping into __other__. + // Operators opt into the named + __other__ split only by listing models. + if len(p.Models) == 0 { + return model + } + for _, selected := range p.Models { + if selected == model { + return model + } + } + return service.ChannelMonitorV2OtherModel + } + // Platform not configured: still show the real model so traffic is visible. + return model +} +func channelMonitorV2ModelSelected(filter service.ChannelMonitorV2Filter, cfg service.ChannelMonitorV2Config, platform, model string) bool { + if len(filter.Models) == 0 { + return true + } + display := channelMonitorV2DisplayModel(cfg, platform, model) + for _, selected := range filter.Models { + if selected == display { + return true + } + } + return false +} +func channelMonitorV2ModelLabel(model string) string { + if model == service.ChannelMonitorV2OtherModel { + return "Other models" + } + return model +} + +func channelMonitorV2CoveredMinutes(filter service.ChannelMonitorV2Filter, coverage service.ChannelMonitorV2Coverage) float64 { + start := filter.Start + if coverage.CoverageStart.After(start) { + start = coverage.CoverageStart + } + end := filter.End + if !coverage.DataThrough.IsZero() && coverage.DataThrough.Before(end) { + end = coverage.DataThrough + } + if !start.Before(end) { + return 1 + } + return end.Sub(start).Minutes() +} + +// Usage and error sources can have different retention windows. Composite +// health metrics must use their common interval; otherwise error rates mix +// unlike periods and RPM/TPM use a denominator shorter than their numerator. +func channelMonitorV2CommonCoverageFilter(filter service.ChannelMonitorV2Filter, coverage service.ChannelMonitorV2Coverage) service.ChannelMonitorV2Filter { + if coverage.CoverageStart.After(filter.Start) { + filter.Start = coverage.CoverageStart + } + if !coverage.DataThrough.IsZero() && coverage.DataThrough.Before(filter.End) { + filter.End = coverage.DataThrough + } + return filter +} +func intersectStrings(a, b []string) []string { + set := map[string]struct{}{} + for _, v := range b { + set[v] = struct{}{} + } + out := []string{} + for _, v := range a { + if _, ok := set[v]; ok { + out = append(out, v) + } + } + return out +} + +func containsString(values []string, target string) bool { + for _, value := range values { + if value == target { + return true + } + } + return false +} + +func intersectInt64(a, b []int64) []int64 { + set := map[int64]struct{}{} + for _, v := range b { + set[v] = struct{}{} + } + out := []int64{} + for _, v := range a { + if _, ok := set[v]; ok { + out = append(out, v) + } + } + return out +} + +// shiftSQLPlaceholders shifts existing positional placeholders by offset. +func shiftSQLPlaceholders(query string, offset int) string { + for i := 64; i >= 1; i-- { + query = strings.ReplaceAll(query, fmt.Sprintf("$%d", i), fmt.Sprintf("$%d", i+offset)) + } + return query +} + +type metricAccumulator struct { + success, errors, upstreamAffected, upstreamAttempts, input, output, cacheCreation, cacheRead, ttftSum, ttftCount, durationSum, durationCount int64 + hist map[string]map[int64]int64 +} + +func newMetricAccumulator() *metricAccumulator { + return &metricAccumulator{hist: map[string]map[int64]int64{"ttft": {}, "duration": {}}} +} +func (a *metricAccumulator) addFact(f channelMonitorV2Fact) { + a.success += f.Success + a.errors += f.Errors + a.upstreamAffected += f.UpstreamAffected + a.upstreamAttempts += f.UpstreamAttempts + a.input += f.Input + a.output += f.Output + a.cacheCreation += f.CacheCreation + a.cacheRead += f.CacheRead + a.ttftSum += f.TTFTSum + a.ttftCount += f.TTFTCount + a.durationSum += f.DurationSum + a.durationCount += f.DurationCount +} +func (a *metricAccumulator) addHistogram(h channelMonitorV2Histogram) { + if a.hist[h.Metric] == nil { + a.hist[h.Metric] = map[int64]int64{} + } + a.hist[h.Metric][h.UpperBound] += h.Count +} +func (a *metricAccumulator) metric(minutes float64, admin bool) service.ChannelMonitorV2Metric { + requests := a.success + a.errors + tokens := a.input + a.output + a.cacheCreation + a.cacheRead + denom := a.input + a.cacheCreation + a.cacheRead + if minutes <= 0 { + minutes = 1 + } + m := service.ChannelMonitorV2Metric{SuccessRequests: a.success, ErrorRequests: a.errors, RequestCount: requests, InputTokens: a.input, OutputTokens: a.output, CacheCreationTokens: a.cacheCreation, CacheReadTokens: a.cacheRead, TokenCount: tokens, RPM: float64(requests) / minutes, TPM: float64(tokens) / minutes, CacheRateNumerator: a.cacheRead, CacheRateDenominator: denom, TTFT: latencyMetric(a.ttftSum, a.ttftCount, a.hist["ttft"]), Duration: latencyMetric(a.durationSum, a.durationCount, a.hist["duration"])} + if requests > 0 { + m.ErrorRate = float64(a.errors) / float64(requests) + m.SuccessRate = float64(a.success) / float64(requests) + } + if denom > 0 { + m.CacheRate = float64(a.cacheRead) / float64(denom) + } + if admin { + affected, attempts := a.upstreamAffected, a.upstreamAttempts + m.UpstreamAffectedRequests = &affected + m.UpstreamAttemptCount = &attempts + } + return m +} +func latencyMetric(sum, count int64, hist map[int64]int64) service.ChannelMonitorV2Latency { + result := service.ChannelMonitorV2Latency{SampleCount: count} + if count > 0 { + avg := float64(sum) / float64(count) + result.AvgMs = &avg + } + result.P50Ms = histPercentile(hist, 0.50) + result.P90Ms = histPercentile(hist, 0.90) + result.P95Ms = histPercentile(hist, 0.95) + return result +} +func histPercentile(hist map[int64]int64, p float64) *int64 { + var total int64 + keys := make([]int64, 0, len(hist)) + for bound, count := range hist { + keys = append(keys, bound) + total += count + } + if total == 0 { + return nil + } + sort.Slice(keys, func(i, j int) bool { return keys[i] < keys[j] }) + target := int64(float64(total)*p + 0.999999) + var cumulative int64 + for _, bound := range keys { + cumulative += hist[bound] + if cumulative >= target { + v := bound + return &v + } + } + v := keys[len(keys)-1] + return &v +} + +// applyIgnoredErrors rewrites ErrorRate so ignored categories do not inflate the +// scored error rate used by health. Absolute ErrorRequests / RequestCount stay +// for volume/RPM. SuccessRate remains true success/request (ignored errors are +// not treated as successes). +func applyIgnoredErrors(m *service.ChannelMonitorV2Metric, ignoredCount int64) { + if m == nil || ignoredCount <= 0 || m.RequestCount <= 0 { + return + } + if ignoredCount > m.ErrorRequests { + ignoredCount = m.ErrorRequests + } + countedErrors := m.ErrorRequests - ignoredCount + if countedErrors < 0 { + countedErrors = 0 + } + m.ErrorRate = float64(countedErrors) / float64(m.RequestCount) + // Keep SuccessRate = SuccessRequests / RequestCount when absolute success is + // known; fall back to residual only if SuccessRequests was never populated. + if m.SuccessRequests > 0 || m.ErrorRequests >= m.RequestCount { + m.SuccessRate = float64(m.SuccessRequests) / float64(m.RequestCount) + } + if m.SuccessRate < 0 { + m.SuccessRate = 0 + } + if m.SuccessRate > 1 { + m.SuccessRate = 1 + } +} + +// loadIgnoredErrorCounts returns per-bucket and total ignored error request counts +// for categories listed in cfg.IgnoredErrorCategories. Buckets use the same +// date_bin alignment as loadFacts when filter.Bucket is set. +func (r *channelMonitorV2Repository) loadIgnoredErrorCounts( + ctx context.Context, + filter service.ChannelMonitorV2Filter, + cfg service.ChannelMonitorV2Config, +) (byBucket map[string]int64, total int64, err error) { + byBucket = map[string]int64{} + if len(cfg.IgnoredErrorCategories) == 0 { + return byBucket, 0, nil + } + where, args, bucketSeconds := channelMonitorV2WhereWithRollup(filter, cfg, "e") + bucketExpr := "e.bucket_start" + groupBy := "e.bucket_start, e.platform, e.model" + if filter.Bucket > 0 && bucketSeconds == 0 { + args = append([]any{fmt.Sprintf("%d seconds", int(filter.Bucket.Seconds()))}, args...) + where = shiftSQLPlaceholders(where, 1) + bucketExpr = "date_bin($1::interval,e.bucket_start,TIMESTAMPTZ '1970-01-01')" + groupBy = bucketExpr + ", e.platform, e.model" + } + args = append(args, pq.Array(cfg.IgnoredErrorCategories), service.ChannelMonitorV2TaxonomyVersion) + catIdx := len(args) - 1 + taxIdx := len(args) + query := fmt.Sprintf( + `SELECT %s, e.platform, e.model, SUM(e.error_requests) + FROM `+channelMonitorV2ErrorMetricsTable(filter)+` e %s + AND e.error_category = ANY($%d) AND e.taxonomy_version = $%d + GROUP BY %s`, + bucketExpr, where, catIdx, taxIdx, groupBy, + ) + rows, err := r.db.QueryContext(ctx, query, args...) + if err != nil { + return byBucket, 0, err + } + defer func() { _ = rows.Close() }() + for rows.Next() { + var bucket time.Time + var platform, model string + var count int64 + if err := rows.Scan(&bucket, &platform, &model, &count); err != nil { + return byBucket, 0, err + } + if !channelMonitorV2ModelSelected(filter, cfg, platform, model) { + continue + } + key := bucket.UTC().Format(time.RFC3339Nano) + byBucket[key] += count + total += count + } + return byBucket, total, rows.Err() +} + +// loadIgnoredErrorCountsByPlatformModel keys are platform + "\x00" + display model. +func (r *channelMonitorV2Repository) loadIgnoredErrorCountsByPlatformModel( + ctx context.Context, + filter service.ChannelMonitorV2Filter, + cfg service.ChannelMonitorV2Config, +) (byPM map[string]int64, total int64, err error) { + byPM = map[string]int64{} + if len(cfg.IgnoredErrorCategories) == 0 { + return byPM, 0, nil + } + where, args, _ := channelMonitorV2WhereWithRollup(filter, cfg, "e") + args = append(args, pq.Array(cfg.IgnoredErrorCategories), service.ChannelMonitorV2TaxonomyVersion) + catIdx := len(args) - 1 + taxIdx := len(args) + query := fmt.Sprintf( + `SELECT e.platform, e.model, SUM(e.error_requests) + FROM `+channelMonitorV2ErrorMetricsTable(filter)+` e %s + AND e.error_category = ANY($%d) AND e.taxonomy_version = $%d + GROUP BY e.platform, e.model`, + where, catIdx, taxIdx, + ) + rows, err := r.db.QueryContext(ctx, query, args...) + if err != nil { + return byPM, 0, err + } + defer func() { _ = rows.Close() }() + for rows.Next() { + var platform, model string + var count int64 + if err := rows.Scan(&platform, &model, &count); err != nil { + return byPM, 0, err + } + if !channelMonitorV2ModelSelected(filter, cfg, platform, model) { + continue + } + display := channelMonitorV2DisplayModel(cfg, platform, model) + byPM[platform+"\x00"+display] += count + total += count + } + return byPM, total, rows.Err() +} + +// loadIgnoredErrorCountsByMatrixKey returns ignored counts keyed by matrix dimension +// and by (dimension, bucket). Uses the same date_bin alignment as GetMatrix facts. +func (r *channelMonitorV2Repository) loadIgnoredErrorCountsByMatrixKey( + ctx context.Context, + filter service.ChannelMonitorV2Filter, + cfg service.ChannelMonitorV2Config, + groupBy service.ChannelMonitorV2GroupBy, +) (byDimBucket map[channelMonitorV2MatrixKey]map[string]int64, byDim map[channelMonitorV2MatrixKey]int64, err error) { + byDimBucket = map[channelMonitorV2MatrixKey]map[string]int64{} + byDim = map[channelMonitorV2MatrixKey]int64{} + if len(cfg.IgnoredErrorCategories) == 0 { + return byDimBucket, byDim, nil + } + where, args, bucketSeconds := channelMonitorV2WhereWithRollup(filter, cfg, "e") + bucketExpr := "e.bucket_start" + groupSQL := "e.bucket_start, e.platform, e.group_id, e.model" + if filter.Bucket > 0 && bucketSeconds == 0 { + args = append([]any{fmt.Sprintf("%d seconds", int(filter.Bucket.Seconds()))}, args...) + where = shiftSQLPlaceholders(where, 1) + bucketExpr = "date_bin($1::interval,e.bucket_start,TIMESTAMPTZ '1970-01-01')" + groupSQL = bucketExpr + ", e.platform, e.group_id, e.model" + } + args = append(args, pq.Array(cfg.IgnoredErrorCategories), service.ChannelMonitorV2TaxonomyVersion) + catIdx := len(args) - 1 + taxIdx := len(args) + query := fmt.Sprintf( + `SELECT %s, e.platform, e.group_id, e.model, SUM(e.error_requests) + FROM `+channelMonitorV2ErrorMetricsTable(filter)+` e %s + AND e.error_category = ANY($%d) AND e.taxonomy_version = $%d + GROUP BY %s`, + bucketExpr, where, catIdx, taxIdx, groupSQL, + ) + rows, err := r.db.QueryContext(ctx, query, args...) + if err != nil { + return byDimBucket, byDim, err + } + defer func() { _ = rows.Close() }() + for rows.Next() { + var bucket time.Time + var platform, model string + var groupID, count int64 + if err := rows.Scan(&bucket, &platform, &groupID, &model, &count); err != nil { + return byDimBucket, byDim, err + } + if !channelMonitorV2ModelSelected(filter, cfg, platform, model) { + continue + } + bucketKey := bucket.UTC().Format(time.RFC3339Nano) + key := channelMonitorV2MatrixDimensionKey(groupBy, cfg, platform, groupID, model) + byDim[key] += count + if byDimBucket[key] == nil { + byDimBucket[key] = map[string]int64{} + } + byDimBucket[key][bucketKey] += count + } + return byDimBucket, byDim, rows.Err() +} diff --git a/backend/internal/repository/channel_monitor_v2_repo_test.go b/backend/internal/repository/channel_monitor_v2_repo_test.go new file mode 100644 index 000000000..14b54eb1f --- /dev/null +++ b/backend/internal/repository/channel_monitor_v2_repo_test.go @@ -0,0 +1,288 @@ +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, "candidate_ids") + require.Contains(t, query, "where bucket_start >= $1 and bucket_start < $2") + require.Contains(t, query, "upstream_affected_requests") + require.Contains(t, query, "jsonb_array_length(upstream_errors) > 0") + // request_id dedup must be time-bounded (no full-history scan). + require.Contains(t, query, "interval '90 minutes'") + require.Contains(t, query, "current_error.created_at >= $1 - interval '90 minutes'") +} + +func TestChannelMonitorV2UsageSuccessExcludesCyberBillingRows(t *testing.T) { + for _, query := range []string{channelMonitorV2UsageMetricsSQL, channelMonitorV2UserMetricsSQL} { + require.Contains(t, query, "COALESCE(ul.request_type, 0) NOT IN (4, 6)") + require.Contains(t, query, "ul.actual_cost > 0") + } + require.Contains(t, channelMonitorV2PlatformSQL, "g.platform = 'composite'") + require.Contains(t, channelMonitorV2PlatformSQL, "a.platform") + require.Contains(t, channelMonitorV2HistogramSQL, "ul.actual_cost > 0") +} + +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 TestChannelMonitorV2HistoryCoverageCompleteIgnoresTrailingLag(t *testing.T) { + start := time.Date(2026, 8, 7, 2, 20, 0, 0, time.UTC) + // History reaches the window start → complete even if data_through is behind filter.End. + require.True(t, channelMonitorV2HistoryCoverageComplete(start, start)) + require.True(t, channelMonitorV2HistoryCoverageComplete(start.Add(-time.Hour), start)) + // Backfill still short of the window start → incomplete. + require.False(t, channelMonitorV2HistoryCoverageComplete(start.Add(time.Hour), start)) + require.False(t, channelMonitorV2HistoryCoverageComplete(time.Time{}, start)) +} + +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())) +} + +func TestSameFixedRollupBucket(t *testing.T) { + start := time.Date(2026, 8, 7, 10, 0, 0, 0, time.UTC) + require.True(t, sameFixedRollupBucket(start, start.Add(10*time.Minute), 86400)) + require.False(t, sameFixedRollupBucket(start, start.Add(15*time.Hour), 43200)) + require.False(t, sameFixedRollupBucket(start, start.Add(24*time.Hour), 86400)) +} + +// 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)) +} diff --git a/backend/internal/repository/migrations_runner.go b/backend/internal/repository/migrations_runner.go index 31be8670d..169a01675 100644 --- a/backend/internal/repository/migrations_runner.go +++ b/backend/internal/repository/migrations_runner.go @@ -83,6 +83,9 @@ var migrationChecksumCompatibilityRules = map[string]migrationChecksumCompatibil "123_fix_legacy_auth_source_grant_on_signup_defaults.sql": newMigrationChecksumCompatibilityRule("2ce43c2cd89e9f9e1febd34a407ed9e84d177386c5544b6f02c1f58a21129f57", "6cd33422f215dcd1f486ab6f35c0ea5805d9ca69bb25906d94bc649156657145"), "159_batch_image_foundation.sql": newMigrationChecksumCompatibilityRule("d902b70982025ec519749faf058aab7631e82c3f48167b9a4ae4db718eb72cce", "82da85b5d98e67a0507647b873a40373e84538e4adafdeed6767c0ac8b6570b2"), "161_batch_image_pricing_snapshot.sql": newMigrationChecksumCompatibilityRule("4012af3e43636cb6af22e0176d59d1fcc70615c0f310194329461ae462c4fbd6", "96d915c9b7a6941ae99039e0ff3f1a61481eb9bddd933d11c6fadb2274554e87"), + // 195 originally seeded mode=v2; flipped to v1 (safe default / opt-in v2). Existing DBs + // that already applied the v2 seed keep their row and the historical checksum. + "195_channel_monitor_mode.sql": newMigrationChecksumCompatibilityRule("13f3792f3e3e53ee96e26415c884cf8062c77172824b54fcc9a8c0c2b1f185ec", "4c74fe33ef2274cc72e1bb49671e651274532c034b29f5b2982c2a4c88d101a6"), // 220 originally cleared video prices for all non-grok platforms (including composite); // composite is now preserved because it may route to Grok accounts. "220_clear_non_grok_video_generation_config.sql": newMigrationChecksumCompatibilityRule("85e320b9ec64f2d3fcd8cf705b2b4e76a7b49f7a57140c14bff97f32691c818b", "3da48c8fdffe6390325f43d08b8e353e0a365df43d44a78dbbe655d0deb18402"), diff --git a/backend/internal/repository/wire.go b/backend/internal/repository/wire.go index 84cecf59e..34c1a1b9d 100644 --- a/backend/internal/repository/wire.go +++ b/backend/internal/repository/wire.go @@ -98,6 +98,7 @@ var ProviderSet = wire.NewSet( NewTLSFingerprintProfileRepository, NewChannelRepository, NewChannelMonitorRepository, + NewChannelMonitorV2Repository, NewChannelMonitorRequestTemplateRepository, NewContentModerationRepository, NewAffiliateRepository, diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index b069711b5..196b08198 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -984,6 +984,8 @@ func TestAPIContracts(t *testing.T) { "balance_low_notify_recharge_url": "", "account_quota_notify_emails": [], "channel_monitor_enabled": true, + "channel_monitor_mode": "v1", + "channel_monitor_hide_throughput": true, "channel_monitor_default_interval_seconds": 60, "available_channels_enabled": false, "model_plaza_enabled": false, @@ -1291,6 +1293,8 @@ func TestAPIContracts(t *testing.T) { "balance_low_notify_recharge_url": "", "account_quota_notify_emails": [], "channel_monitor_enabled": true, + "channel_monitor_mode": "v1", + "channel_monitor_hide_throughput": true, "channel_monitor_default_interval_seconds": 60, "available_channels_enabled": false, "model_plaza_enabled": false, diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index dcce82c41..149d4f95b 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -3,6 +3,7 @@ package routes import ( "github.com/Wei-Shaw/sub2api/internal/handler" + "github.com/Wei-Shaw/sub2api/internal/pkg/response" "github.com/Wei-Shaw/sub2api/internal/server/middleware" "github.com/Wei-Shaw/sub2api/internal/service" @@ -106,7 +107,8 @@ func RegisterAdminRoutes( registerChannelRoutes(admin, h) // 渠道监控 - registerChannelMonitorRoutes(admin, h) + registerChannelMonitorRoutes(admin, h, settingService) + registerChannelMonitorV2Routes(admin, h, settingService) // 风控中心 registerContentModerationRoutes(admin, h) @@ -733,8 +735,10 @@ func registerChannelRoutes(admin *gin.RouterGroup, h *handler.Handlers) { } } -func registerChannelMonitorRoutes(admin *gin.RouterGroup, h *handler.Handlers) { +func registerChannelMonitorRoutes(admin *gin.RouterGroup, h *handler.Handlers, settingService *service.SettingService) { + guard := channelMonitorAdminFeatureGuard(settingService) monitors := admin.Group("/channel-monitors") + monitors.Use(guard) { monitors.GET("", h.Admin.ChannelMonitor.List) monitors.POST("", h.Admin.ChannelMonitor.Create) @@ -747,6 +751,7 @@ func registerChannelMonitorRoutes(admin *gin.RouterGroup, h *handler.Handlers) { } templates := admin.Group("/channel-monitor-templates") + templates.Use(guard) { templates.GET("", h.Admin.ChannelMonitorTemplate.List) templates.POST("", h.Admin.ChannelMonitorTemplate.Create) @@ -777,3 +782,64 @@ func registerAffiliateRoutes(admin *gin.RouterGroup, h *handler.Handlers) { } } } + +func registerChannelMonitorV2Routes(admin *gin.RouterGroup, h *handler.Handlers, settingService *service.SettingService) { + // Config GET/PUT: feature enabled only (operators can prepare V2 before flipping mode). + // Read/matrix endpoints: require mode=v2 so V1 deployments do not serve passive data. + featureGuard := channelMonitorAdminFeatureGuard(settingService) + modeV2Guard := channelMonitorModeV2Guard(settingService) + + monitor := admin.Group("/channel-monitor-v2") + { + config := monitor.Group("") + config.Use(featureGuard) + { + config.GET("/config", h.ChannelMonitorV2.GetConfig) + config.PUT("/config", h.ChannelMonitorV2.UpdateConfig) + } + reads := monitor.Group("") + reads.Use(modeV2Guard) + { + reads.GET("/dimensions", h.ChannelMonitorV2.Dimensions) + reads.GET("/snapshot", h.ChannelMonitorV2.AdminSnapshot) + reads.GET("/models", h.ChannelMonitorV2.AdminModels) + reads.GET("/matrix", h.ChannelMonitorV2.AdminMatrix) + reads.GET("/errors", h.ChannelMonitorV2.Errors) + reads.GET("/users", h.ChannelMonitorV2.AdminUsers) + } + } +} + +func channelMonitorAdminFeatureGuard(settingService *service.SettingService) gin.HandlerFunc { + return func(c *gin.Context) { + if settingService != nil && settingService.GetChannelMonitorRuntime(c.Request.Context()).Enabled { + c.Next() + return + } + response.ErrorFrom(c, service.ErrChannelMonitorDisabled) + c.Abort() + } +} + +// channelMonitorModeV2Guard requires feature enabled and channel_monitor_mode=v2. +func channelMonitorModeV2Guard(settingService *service.SettingService) gin.HandlerFunc { + return func(c *gin.Context) { + if settingService == nil { + response.ErrorFrom(c, service.ErrChannelMonitorDisabled) + c.Abort() + return + } + rt := settingService.GetChannelMonitorRuntime(c.Request.Context()) + if !rt.Enabled { + response.ErrorFrom(c, service.ErrChannelMonitorDisabled) + c.Abort() + return + } + if !rt.PassiveAggregationAllowed() { + response.ErrorFrom(c, service.ErrChannelMonitorModeMismatch) + c.Abort() + return + } + c.Next() + } +} diff --git a/backend/internal/server/routes/channel_monitor_feature_gate_test.go b/backend/internal/server/routes/channel_monitor_feature_gate_test.go new file mode 100644 index 000000000..1881daa4e --- /dev/null +++ b/backend/internal/server/routes/channel_monitor_feature_gate_test.go @@ -0,0 +1,173 @@ +package routes + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +// channelMonitorRouteSettingRepoStub is a minimal SettingRepository for route guards. +type channelMonitorRouteSettingRepoStub struct { + values map[string]string +} + +func (s *channelMonitorRouteSettingRepoStub) Get(context.Context, string) (*service.Setting, error) { + panic("unexpected Get call") +} + +func (s *channelMonitorRouteSettingRepoStub) GetValue(_ context.Context, key string) (string, error) { + return s.values[key], nil +} + +func (s *channelMonitorRouteSettingRepoStub) Set(context.Context, string, string) error { + panic("unexpected Set call") +} + +func (s *channelMonitorRouteSettingRepoStub) GetMultiple(_ context.Context, keys []string) (map[string]string, error) { + out := make(map[string]string, len(keys)) + for _, key := range keys { + if value, ok := s.values[key]; ok { + out[key] = value + } + } + return out, nil +} + +func (s *channelMonitorRouteSettingRepoStub) SetMultiple(context.Context, map[string]string) error { + panic("unexpected SetMultiple call") +} + +func (s *channelMonitorRouteSettingRepoStub) GetAll(context.Context) (map[string]string, error) { + panic("unexpected GetAll call") +} + +func (s *channelMonitorRouteSettingRepoStub) Delete(context.Context, string) error { + panic("unexpected Delete call") +} + +func newChannelMonitorRouteSettings(enabled bool) *service.SettingService { + value := "false" + if enabled { + value = "true" + } + return service.NewSettingService(&channelMonitorRouteSettingRepoStub{ + values: map[string]string{ + service.SettingKeyChannelMonitorEnabled: value, + }, + }, &config.Config{}) +} + +func newChannelMonitorModeSettings(enabled bool, mode string) *service.SettingService { + enabledVal := "false" + if enabled { + enabledVal = "true" + } + return service.NewSettingService(&channelMonitorRouteSettingRepoStub{ + values: map[string]string{ + service.SettingKeyChannelMonitorEnabled: enabledVal, + service.SettingKeyChannelMonitorMode: mode, + }, + }, &config.Config{}) +} + +func TestChannelMonitorAdminFeatureGuard(t *testing.T) { + tests := []struct { + name string + svc *service.SettingService + wantStatus int + }{ + { + name: "nil setting service blocks", + svc: nil, + wantStatus: http.StatusForbidden, + }, + { + name: "disabled blocks", + svc: newChannelMonitorRouteSettings(false), + wantStatus: http.StatusForbidden, + }, + { + name: "enabled allows", + svc: newChannelMonitorRouteSettings(true), + wantStatus: http.StatusOK, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gin.SetMode(gin.TestMode) + + router := gin.New() + router.Use(channelMonitorAdminFeatureGuard(tt.svc)) + router.GET("/test", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"ok": true}) + }) + + rec := httptest.NewRecorder() + req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/test", nil) + router.ServeHTTP(rec, req) + + require.Equal(t, tt.wantStatus, rec.Code) + if tt.wantStatus == http.StatusForbidden { + require.Contains(t, rec.Body.String(), "CHANNEL_MONITOR_DISABLED") + } + }) + } +} + +func TestChannelMonitorModeV2Guard(t *testing.T) { + tests := []struct { + name string + svc *service.SettingService + wantStatus int + wantCode string + }{ + { + name: "nil blocks as disabled", + svc: nil, + wantStatus: http.StatusForbidden, + wantCode: "CHANNEL_MONITOR_DISABLED", + }, + { + name: "feature off blocks", + svc: newChannelMonitorModeSettings(false, service.ChannelMonitorModeV2), + wantStatus: http.StatusForbidden, + wantCode: "CHANNEL_MONITOR_DISABLED", + }, + { + name: "mode v1 blocks with mode mismatch", + svc: newChannelMonitorModeSettings(true, service.ChannelMonitorModeV1), + wantStatus: http.StatusForbidden, + wantCode: "CHANNEL_MONITOR_MODE_MISMATCH", + }, + { + name: "mode v2 allows", + svc: newChannelMonitorModeSettings(true, service.ChannelMonitorModeV2), + wantStatus: http.StatusOK, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + router.Use(channelMonitorModeV2Guard(tt.svc)) + router.GET("/test", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"ok": true}) + }) + rec := httptest.NewRecorder() + req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/test", nil) + router.ServeHTTP(rec, req) + require.Equal(t, tt.wantStatus, rec.Code) + if tt.wantCode != "" { + require.Contains(t, rec.Body.String(), tt.wantCode) + } + }) + } +} diff --git a/backend/internal/server/routes/user.go b/backend/internal/server/routes/user.go index bcb2a8c91..65ecaa037 100644 --- a/backend/internal/server/routes/user.go +++ b/backend/internal/server/routes/user.go @@ -141,5 +141,18 @@ func RegisterUserRoutes( monitors.GET("", h.ChannelMonitor.List) monitors.GET("/:id/status", h.ChannelMonitor.GetStatus) } + + // V2 passive views require feature on + mode=v2. + monitorV2 := authenticated.Group("/channel-monitor-v2") + monitorV2.Use(panelRateLimiter.Heavy()) + monitorV2.Use(channelMonitorModeV2Guard(settingService)) + { + monitorV2.GET("/dimensions", h.ChannelMonitorV2.Dimensions) + monitorV2.GET("/snapshot", h.ChannelMonitorV2.Snapshot) + monitorV2.GET("/models", h.ChannelMonitorV2.Models) + monitorV2.GET("/matrix", h.ChannelMonitorV2.Matrix) + monitorV2.GET("/errors", h.ChannelMonitorV2.Errors) + monitorV2.GET("/users", h.ChannelMonitorV2.Users) + } } } diff --git a/backend/internal/service/channel_monitor_const.go b/backend/internal/service/channel_monitor_const.go index 2ee8eabde..6a41add29 100644 --- a/backend/internal/service/channel_monitor_const.go +++ b/backend/internal/service/channel_monitor_const.go @@ -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", + ) +) diff --git a/backend/internal/service/channel_monitor_probe_retirement_test.go b/backend/internal/service/channel_monitor_probe_retirement_test.go new file mode 100644 index 000000000..97bc8bbb4 --- /dev/null +++ b/backend/internal/service/channel_monitor_probe_retirement_test.go @@ -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, ChannelMonitorModeV1, normalizeChannelMonitorMode("")) + require.Equal(t, ChannelMonitorModeV1, normalizeChannelMonitorMode("v1")) + require.Equal(t, ChannelMonitorModeV2, normalizeChannelMonitorMode("v2")) + require.Equal(t, ChannelMonitorModeV1, 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()) +} diff --git a/backend/internal/service/channel_monitor_runner.go b/backend/internal/service/channel_monitor_runner.go index dbf03c239..9da063c17 100644 --- a/backend/internal/service/channel_monitor_runner.go +++ b/backend/internal/service/channel_monitor_runner.go @@ -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", diff --git a/backend/internal/service/channel_monitor_service.go b/backend/internal/service/channel_monitor_service.go index 9cc793b43..248b72133 100644 --- a/backend/internal/service/channel_monitor_service.go +++ b/backend/internal/service/channel_monitor_service.go @@ -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 diff --git a/backend/internal/service/channel_monitor_v2.go b/backend/internal/service/channel_monitor_v2.go new file mode 100644 index 000000000..8d6198918 --- /dev/null +++ b/backend/internal/service/channel_monitor_v2.go @@ -0,0 +1,1079 @@ +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"` + // RequestedEnd is the exclusive upper bound of the UI-selected window + // (filter.End). Charts/matrices should plot [RequestedStart, RequestedEnd) + // even when CoverageStart is later (partial backfill). + RequestedEnd time.Time `json:"requested_end"` + 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 is true when aggregated history reaches the requested + // window start (backfill / product coverage for this range is done). + // Trailing lag — data_through behind filter.End because UI ends align to + // the next bucket and may sit slightly in the future — does not make this + // false once history is covered. + CoverageComplete bool `json:"coverage_complete"` + BucketSeconds int `json:"bucket_seconds"` + // Bootstrap is set while the first-upgrade historical backfill toward the + // product windows (90m / 24h / 7d / 30d) is still running. Omitted or + // inactive once the 30d UI range is fully covered; longer retention (90d) + // may continue silently without a progress banner. + Bootstrap *ChannelMonitorV2Bootstrap `json:"bootstrap,omitempty"` +} + +// ChannelMonitorV2Bootstrap reports first-upgrade aggregation progress for the UI. +// Progress is measured against the longest product window (30d), not full 90d retention. +type ChannelMonitorV2Bootstrap struct { + // Active is true while historical backfill has not yet covered the 30d product window. + Active bool `json:"active"` + // ProgressPercent is 0–100 of history covered from now back toward TargetStart. + ProgressPercent int `json:"progress_percent"` + // CoveredFrom is the earliest minute already recomputed (backfill cursor). + CoveredFrom time.Time `json:"covered_from,omitempty"` + // TargetStart is now−30d (product bootstrap goal for 90m/24h/7d/30d). + TargetStart time.Time `json:"target_start,omitempty"` +} + +// ChannelMonitorV2AggregationWatermark is the durable aggregator cursor state. +type ChannelMonitorV2AggregationWatermark struct { + UsageCoverageStart time.Time + ErrorCoverageStart time.Time + DataThrough time.Time + LastSuccessfulAt time.Time + // BackfillCursor is the earliest start already recomputed; zero when never set. + BackfillCursor time.Time + // HasData is true once any successful recompute wrote data_through. + HasData bool +} + +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) + // GetAggregationWatermark loads durable backfill / coverage cursors for the + // passive aggregator (and bootstrap progress). Missing row → zero value, nil error. + GetAggregationWatermark(ctx context.Context) (*ChannelMonitorV2AggregationWatermark, error) + RecomputeRange(ctx context.Context, start, end time.Time) error +} + +// ChannelMonitorV2BootstrapProductWindow is the longest UI range that must be +// filled on first upgrade before the bootstrap banner disappears (30d). +const ChannelMonitorV2BootstrapProductWindow = 30 * 24 * time.Hour + +// ChannelMonitorV2BootstrapProgress builds the optional UI progress payload. +// now and coveredFrom should be UTC minute-truncated when possible. +// When history is complete for the 30d product window, returns nil (no banner). +func ChannelMonitorV2BootstrapProgress(now, coveredFrom time.Time, hasData bool) *ChannelMonitorV2Bootstrap { + now = now.UTC().Truncate(time.Minute) + targetStart := now.Add(-ChannelMonitorV2BootstrapProductWindow) + if !hasData { + return &ChannelMonitorV2Bootstrap{ + Active: true, + ProgressPercent: 0, + TargetStart: targetStart, + } + } + if coveredFrom.IsZero() { + // Have data_through but no cursor yet — treat as barely started. + return &ChannelMonitorV2Bootstrap{ + Active: true, + ProgressPercent: 0, + TargetStart: targetStart, + } + } + coveredFrom = coveredFrom.UTC().Truncate(time.Minute) + if !coveredFrom.After(targetStart) { + // 30d product windows fully covered; hide progress (90d retention may continue). + return nil + } + total := now.Sub(targetStart).Seconds() + if total <= 0 { + return nil + } + done := now.Sub(coveredFrom).Seconds() + if done < 0 { + done = 0 + } + if done > total { + done = total + } + // Round to nearest percent; any non-zero history shows at least 1% so the + // bar moves after the first 2h bootstrap tick (2h/30d ≈ 0.3%). + pct := int((done/total)*100 + 0.5) + if done > 0 && pct < 1 { + pct = 1 + } + if pct > 100 { + pct = 100 + } + if pct >= 100 { + // Cursor still slightly after target due to rounding — keep active until + // coveredFrom <= targetStart so the banner does not flash 100% then vanish. + pct = 99 + } + return &ChannelMonitorV2Bootstrap{ + Active: true, + ProgressPercent: pct, + CoveredFrom: coveredFrom, + TargetStart: targetStart, + } +} + +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 { + return false + } + // Privacy is fail-closed: an absent or unavailable settings reader must not + // expose fleet-scale rates to ordinary users. + if s == nil || s.settings == nil { + return true + } + 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" +} diff --git a/backend/internal/service/channel_monitor_v2_aggregator.go b/backend/internal/service/channel_monitor_v2_aggregator.go new file mode 100644 index 000000000..3c18b0375 --- /dev/null +++ b/backend/internal/service/channel_monitor_v2_aggregator.go @@ -0,0 +1,419 @@ +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" + // Retention 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 + // First tick after upgrade prioritizes the default 90m view (with small padding). + channelMonitorV2BootstrapFirst = 2 * time.Hour + // Always refresh a small trailing window so late writes land without + // re-aggregating large history every tick. + channelMonitorV2RecentOverlap = 10 * time.Minute + + // Gentle backfill: small adaptive chunks, never default 24h hammering. + // Initial historical chunk after the 2h seed. + channelMonitorV2BackfillChunkInit = time.Hour + channelMonitorV2MinBackfillChunk = 15 * time.Minute + // Depth-based ceilings (product phases 90m → 1d → 7d → 30d → 90d). + channelMonitorV2MaxChunkNear1d = 2 * time.Hour + channelMonitorV2MaxChunkNear7d = 4 * time.Hour + channelMonitorV2MaxChunkFar = 6 * time.Hour + + // Soft adaptive timing: grow only when a recompute is clearly cheap. + channelMonitorV2GrowChunkUnder = 15 * time.Second + channelMonitorV2MaxBackoff = 10 * time.Minute +) + +// 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 is the earliest minute already recomputed (mirrors DB cursor). + // Zero means "not yet loaded from durable watermark this process". + backfillAt time.Time + backfillChunk time.Duration + backfillFailures int + // nextWaitFloor is applied after runOnce when failures require backoff. + nextWaitFloor time.Duration + // cursorLoaded is true after the first successful watermark read (or init). + cursorLoaded bool + // hasAggregated is true once any recompute in this process (or durable data) exists. + hasAggregated bool + 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), + backfillChunk: channelMonitorV2BackfillChunkInit, + } +} + +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() + // Hard gate: never compress bootstrap to multi-Hz ticks. Soft gate: on + // repeated failures raise the wait floor (exponential backoff). + s.mu.Lock() + floor := s.nextWaitFloor + s.mu.Unlock() + if floor > interval { + interval = floor + } + 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 err := s.ensureCursor(ctx, now); err != nil { + logger.LegacyPrintf("service.channel_monitor_v2", "[ChannelMonitorV2] load watermark failed: %v", err) + return + } + + s.mu.Lock() + cursor := s.backfillAt + hasData := s.hasAggregated + s.mu.Unlock() + + // Phase 1 (first upgrade / empty): seed the default 90m UI window quickly. + if !hasData || cursor.IsZero() { + start := now.Add(-channelMonitorV2BootstrapFirst) + started := time.Now() + if err := s.repo.RecomputeRange(ctx, start, now); err != nil { + logger.LegacyPrintf("service.channel_monitor_v2", "[ChannelMonitorV2] bootstrap recent aggregation failed: %v", err) + s.recordBackfillFailure(now, cursor) + return + } + s.recordBackfillSuccess(start, time.Since(started), now) + return + } + + // Always refresh the trailing overlap so late usage/error writes land in 1m facts. + 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 + } + + // Phase 2: walk history backward at most one chunk per tick until retention max (90d). + // Product UI (30d) fills first; remaining 30–90d continues silently. + retentionCutoff := now.Add(-channelMonitorV2RetentionMax) + if !cursor.After(retentionCutoff) { + return + } + end := cursor + s.mu.Lock() + chunk := s.backfillChunk + s.mu.Unlock() + if chunk <= 0 { + chunk = channelMonitorV2BackfillChunkInit + } + maxChunk := channelMonitorV2MaxChunkForDepth(now, end) + if chunk > maxChunk { + chunk = maxChunk + } + if chunk < channelMonitorV2MinBackfillChunk { + chunk = channelMonitorV2MinBackfillChunk + } + start := end.Add(-chunk) + // Once bootstrap reaches historical data, keep chunks on day boundaries so + // daily rollups never depend on 1m rows from two independently pruned chunks. + if end.Before(now.Add(-7 * 24 * time.Hour)) { + aligned := end.Add(-chunk).Truncate(24 * time.Hour) + if aligned.Before(end) { + start = aligned + } + } + if start.Before(retentionCutoff) { + start = retentionCutoff + } + if !start.Before(end) { + return + } + started := time.Now() + 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) + s.recordBackfillFailure(now, end) + return + } + s.recordBackfillSuccess(start, time.Since(started), now) +} + +// channelMonitorV2MaxChunkForDepth returns the hard ceiling for a historical +// chunk ending at `end` (earliest already covered / next walk end). +func channelMonitorV2MaxChunkForDepth(now, end time.Time) time.Duration { + age := now.Sub(end) + switch { + case age < 24*time.Hour: + return channelMonitorV2MaxChunkNear1d + case age < 7*24*time.Hour: + return channelMonitorV2MaxChunkNear7d + default: + return channelMonitorV2MaxChunkFar + } +} + +func (s *ChannelMonitorV2Aggregator) recordBackfillSuccess(coveredFrom time.Time, elapsed time.Duration, now time.Time) { + s.mu.Lock() + defer s.mu.Unlock() + s.backfillAt = coveredFrom + s.hasAggregated = true + s.backfillFailures = 0 + s.nextWaitFloor = 0 + maxChunk := channelMonitorV2MaxChunkForDepth(now, coveredFrom) + // Grow slowly only when the recompute was clearly cheap. + if elapsed > 0 && elapsed < channelMonitorV2GrowChunkUnder { + next := s.backfillChunk + if next <= 0 { + next = channelMonitorV2BackfillChunkInit + } + next = time.Duration(float64(next) * 1.5) + if next > maxChunk { + next = maxChunk + } + if next < channelMonitorV2MinBackfillChunk { + next = channelMonitorV2MinBackfillChunk + } + s.backfillChunk = next + return + } + // Keep a healthy chunk within the depth ceiling after success. + if s.backfillChunk <= 0 || s.backfillChunk > maxChunk { + s.backfillChunk = channelMonitorV2BackfillChunkInit + if s.backfillChunk > maxChunk { + s.backfillChunk = maxChunk + } + } +} + +func (s *ChannelMonitorV2Aggregator) recordBackfillFailure(now, end time.Time) { + s.mu.Lock() + defer s.mu.Unlock() + s.backfillFailures++ + // Shrink chunk toward the minimum so large DBs self-throttle. + if s.backfillChunk <= 0 { + s.backfillChunk = channelMonitorV2BackfillChunkInit + } + s.backfillChunk /= 2 + if s.backfillChunk < channelMonitorV2MinBackfillChunk { + s.backfillChunk = channelMonitorV2MinBackfillChunk + } + maxChunk := channelMonitorV2MaxChunkForDepth(now, end) + if s.backfillChunk > maxChunk { + s.backfillChunk = maxChunk + } + // Exponential backoff on wait floor: 1m, 2m, 4m… capped at 10m. + floor := time.Minute << uint(s.backfillFailures-1) + if floor > channelMonitorV2MaxBackoff { + floor = channelMonitorV2MaxBackoff + } + if floor < time.Minute { + floor = time.Minute + } + s.nextWaitFloor = floor +} + +// ensureCursor restores durable backfill_cursor after process restart so progress +// and historical walk continue instead of re-seeding only the last 2h. +func (s *ChannelMonitorV2Aggregator) ensureCursor(ctx context.Context, now time.Time) error { + s.mu.Lock() + loaded := s.cursorLoaded + s.mu.Unlock() + if loaded { + return nil + } + wm, err := s.repo.GetAggregationWatermark(ctx) + if err != nil { + return err + } + s.mu.Lock() + defer s.mu.Unlock() + if s.cursorLoaded { + return nil + } + if wm != nil { + if !wm.BackfillCursor.IsZero() { + s.backfillAt = wm.BackfillCursor.UTC().Truncate(time.Minute) + } + if wm.HasData || !wm.DataThrough.IsZero() { + s.hasAggregated = true + // Legacy rows may have data_through but null backfill_cursor (older workers). + // Infer cursor from data_through − initial window so we do not re-bootstrap + // only 2h and claim zero progress forever. + if s.backfillAt.IsZero() && !wm.DataThrough.IsZero() { + inferred := wm.DataThrough.UTC().Truncate(time.Minute).Add(-channelMonitorV2BootstrapFirst) + if inferred.After(now) { + inferred = now.Add(-channelMonitorV2BootstrapFirst) + } + s.backfillAt = inferred + } + } + } + s.cursorLoaded = true + return nil +} diff --git a/backend/internal/service/channel_monitor_v2_aggregator_test.go b/backend/internal/service/channel_monitor_v2_aggregator_test.go new file mode 100644 index 000000000..5fc6b47fd --- /dev/null +++ b/backend/internal/service/channel_monitor_v2_aggregator_test.go @@ -0,0 +1,49 @@ +//go:build unit + +package service + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestChannelMonitorV2MaxChunkForDepth(t *testing.T) { + now := time.Date(2026, 8, 8, 12, 0, 0, 0, time.UTC) + + // Within last day → tightest ceiling (2h). + require.Equal(t, channelMonitorV2MaxChunkNear1d, channelMonitorV2MaxChunkForDepth(now, now.Add(-2*time.Hour))) + // Between 1d and 7d → 4h. + require.Equal(t, channelMonitorV2MaxChunkNear7d, channelMonitorV2MaxChunkForDepth(now, now.Add(-2*24*time.Hour))) + // Older than 7d → 6h (never 24h default). + require.Equal(t, channelMonitorV2MaxChunkFar, channelMonitorV2MaxChunkForDepth(now, now.Add(-10*24*time.Hour))) + require.Less(t, channelMonitorV2MaxChunkFar, 24*time.Hour) + require.Equal(t, time.Hour, channelMonitorV2BackfillChunkInit) + require.Equal(t, 15*time.Minute, channelMonitorV2MinBackfillChunk) +} + +func TestChannelMonitorV2AggregatorAdaptiveChunk(t *testing.T) { + s := NewChannelMonitorV2Aggregator(nil, nil, nil) + now := time.Date(2026, 8, 8, 12, 0, 0, 0, time.UTC) + cursor := now.Add(-3 * time.Hour) + + // Failure shrinks chunk and sets backoff floor. + s.backfillChunk = 2 * time.Hour + s.recordBackfillFailure(now, cursor) + require.Equal(t, time.Hour, s.backfillChunk) + require.Equal(t, time.Minute, s.nextWaitFloor) + require.Equal(t, 1, s.backfillFailures) + + // Repeated failure halves again and raises floor. + s.recordBackfillFailure(now, cursor) + require.Equal(t, 30*time.Minute, s.backfillChunk) + require.Equal(t, 2*time.Minute, s.nextWaitFloor) + + // Fast success grows within depth ceiling and clears backoff. + s.recordBackfillSuccess(cursor.Add(-30*time.Minute), 5*time.Second, now) + require.Equal(t, 0, s.backfillFailures) + require.Equal(t, time.Duration(0), s.nextWaitFloor) + require.Greater(t, s.backfillChunk, 30*time.Minute) + require.LessOrEqual(t, s.backfillChunk, channelMonitorV2MaxChunkForDepth(now, cursor.Add(-30*time.Minute))) +} diff --git a/backend/internal/service/channel_monitor_v2_error_taxonomy.go b/backend/internal/service/channel_monitor_v2_error_taxonomy.go new file mode 100644 index 000000000..403458e16 --- /dev/null +++ b/backend/internal/service/channel_monitor_v2_error_taxonomy.go @@ -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 +} diff --git a/backend/internal/service/channel_monitor_v2_test.go b/backend/internal/service/channel_monitor_v2_test.go new file mode 100644 index 000000000..9172b333d --- /dev/null +++ b/backend/internal/service/channel_monitor_v2_test.go @@ -0,0 +1,469 @@ +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 (s *channelMonitorV2RepoStub) GetAggregationWatermark(context.Context) (*ChannelMonitorV2AggregationWatermark, error) { + return &ChannelMonitorV2AggregationWatermark{}, nil +} + +func TestChannelMonitorV2BootstrapProgress(t *testing.T) { + now := time.Date(2026, 8, 7, 12, 0, 0, 0, time.UTC) + + t.Run("no data shows active zero progress", func(t *testing.T) { + b := ChannelMonitorV2BootstrapProgress(now, time.Time{}, false) + require.NotNil(t, b) + require.True(t, b.Active) + require.Equal(t, 0, b.ProgressPercent) + require.Equal(t, now.Add(-ChannelMonitorV2BootstrapProductWindow), b.TargetStart) + }) + + t.Run("2h seed is small percent of 30d", func(t *testing.T) { + covered := now.Add(-2 * time.Hour) + b := ChannelMonitorV2BootstrapProgress(now, covered, true) + require.NotNil(t, b) + require.True(t, b.Active) + require.Greater(t, b.ProgressPercent, 0) + require.Less(t, b.ProgressPercent, 5) + }) + + t.Run("30d covered hides bootstrap", func(t *testing.T) { + covered := now.Add(-ChannelMonitorV2BootstrapProductWindow) + b := ChannelMonitorV2BootstrapProgress(now, covered, true) + require.Nil(t, b) + }) + + t.Run("beyond 30d also hides", func(t *testing.T) { + covered := now.Add(-60 * 24 * time.Hour) + b := ChannelMonitorV2BootstrapProgress(now, covered, true) + require.Nil(t, b) + }) +} + +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.Zero(t, snap.Metrics.RPM) +} + +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) +} diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go index 3557f1d2a..1e38b1221 100644 --- a/backend/internal/service/domain_constants.go +++ b/backend/internal/service/domain_constants.go @@ -404,10 +404,24 @@ const ( // When false: runner skips scheduling and user-facing endpoints return an empty list. SettingKeyChannelMonitorEnabled = "channel_monitor_enabled" + // SettingKeyChannelMonitorMode selects exclusive implementation: + // "v1" active probes, "v2" passive aggregation. Default "v1" (opt-in to v2). + SettingKeyChannelMonitorMode = "channel_monitor_mode" + + // ChannelMonitorModeV1/V2 are the only accepted mode values. + ChannelMonitorModeV1 = "v1" + ChannelMonitorModeV2 = "v2" + // SettingKeyChannelMonitorDefaultIntervalSeconds controls the default interval (seconds) // pre-filled when creating a new channel monitor from the admin UI. Range: [15, 3600]. SettingKeyChannelMonitorDefaultIntervalSeconds = "channel_monitor_default_interval_seconds" + // SettingKeyChannelMonitorHideThroughput hides RPM/TPM (and similar absolute + // throughput rates) from non-admin user-facing monitor APIs and UI, so users + // cannot reverse-estimate fleet volume from rates × window length. + // Default false (show rates). Admin endpoints always keep full metrics. + SettingKeyChannelMonitorHideThroughput = "channel_monitor_hide_throughput" + // SettingKeyGrokDefaultTextModel is the fallback Grok text model for empty // request models and built-in Grok aliases (e.g. "grok" → this id). Default grok-4.5. SettingKeyGrokDefaultTextModel = "grok_default_text_model" diff --git a/backend/internal/service/openai_gateway_service_test.go b/backend/internal/service/openai_gateway_service_test.go index 97b957acd..b3cb5fbd9 100644 --- a/backend/internal/service/openai_gateway_service_test.go +++ b/backend/internal/service/openai_gateway_service_test.go @@ -2031,8 +2031,10 @@ func TestOpenAIStreamingPreambleKeepaliveUsesDownstreamIdle(t *testing.T) { cfg := &config.Config{ Gateway: config.GatewayConfig{ StreamDataIntervalTimeout: 0, - StreamKeepaliveInterval: 1, - MaxLineSize: defaultMaxLineSize, + // Keepalive is based on *downstream* idle time (last flush to client), + // not upstream event cadence. Interval is seconds (config unit). + StreamKeepaliveInterval: 1, + MaxLineSize: defaultMaxLineSize, }, } svc := &OpenAIGatewayService{cfg: cfg} @@ -2050,11 +2052,14 @@ func TestOpenAIStreamingPreambleKeepaliveUsesDownstreamIdle(t *testing.T) { go func() { defer func() { _ = pw.Close() }() + // Emit preamble/progress quickly so clientOutputStarted is true, then + // leave a real downstream idle gap longer than keepaliveInterval so the + // ticker can write ":\n\n". Frequent upstream ticks used to refresh + // lastDownstreamWriteAt and flake on loaded CI runners. _, _ = pw.Write([]byte("data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_1\"}}\n\n")) - for i := 0; i < 6; i++ { - time.Sleep(250 * time.Millisecond) - _, _ = pw.Write([]byte("data: {\"type\":\"response.in_progress\",\"response\":{\"id\":\"resp_1\"}}\n\n")) - } + time.Sleep(50 * time.Millisecond) + _, _ = pw.Write([]byte("data: {\"type\":\"response.in_progress\",\"response\":{\"id\":\"resp_1\"}}\n\n")) + time.Sleep(1300 * time.Millisecond) _, _ = pw.Write([]byte("data: {\"type\":\"response.completed\",\"response\":{\"usage\":{\"input_tokens\":1,\"output_tokens\":2}}}\n\n")) }() diff --git a/backend/internal/service/setting_parse.go b/backend/internal/service/setting_parse.go index 4e033b354..7701e50e8 100644 --- a/backend/internal/service/setting_parse.go +++ b/backend/internal/service/setting_parse.go @@ -186,7 +186,9 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error { // Channel monitor defaults (enabled, 60s) SettingKeyChannelMonitorEnabled: "true", + SettingKeyChannelMonitorMode: ChannelMonitorModeV1, SettingKeyChannelMonitorDefaultIntervalSeconds: "60", + SettingKeyChannelMonitorHideThroughput: "true", // Grok: safe defaults — no cross-vendor model rewrite unless operators enable it. SettingKeyGrokDefaultTextModel: "grok-4.5", @@ -787,9 +789,13 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin // Channel monitor feature (default: enabled, 60s) result.ChannelMonitorEnabled = !isFalseSettingValue(settings[SettingKeyChannelMonitorEnabled]) + result.ChannelMonitorMode = normalizeChannelMonitorMode(settings[SettingKeyChannelMonitorMode]) result.ChannelMonitorDefaultIntervalSeconds = parseChannelMonitorInterval( settings[SettingKeyChannelMonitorDefaultIntervalSeconds], ) + // 默认隐藏吞吐(迁移 206 的隐私默认):未配置时必须与 setting_public.go 的 + // 公开读取路径给出同一个值,否则管理端看到“未隐藏”而用户端实际已隐藏。 + result.ChannelMonitorHideThroughput = !isFalseSettingValue(settings[SettingKeyChannelMonitorHideThroughput]) // Grok default mapping policy result.GrokDefaultTextModel = strings.TrimSpace(settings[SettingKeyGrokDefaultTextModel]) diff --git a/backend/internal/service/setting_public.go b/backend/internal/service/setting_public.go index ff11fb689..f81717b11 100644 --- a/backend/internal/service/setting_public.go +++ b/backend/internal/service/setting_public.go @@ -227,7 +227,9 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings SettingKeyBalanceLowNotifyRechargeURL, SettingKeyAccountQuotaNotifyEnabled, SettingKeyChannelMonitorEnabled, + SettingKeyChannelMonitorMode, SettingKeyChannelMonitorDefaultIntervalSeconds, + SettingKeyChannelMonitorHideThroughput, SettingKeyAvailableChannelsEnabled, SettingKeyModelPlazaEnabled, SettingKeyModelPlazaRequireAuth, @@ -348,7 +350,9 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings BalanceLowNotifyRechargeURL: settings[SettingKeyBalanceLowNotifyRechargeURL], ChannelMonitorEnabled: !isFalseSettingValue(settings[SettingKeyChannelMonitorEnabled]), + ChannelMonitorMode: normalizeChannelMonitorMode(settings[SettingKeyChannelMonitorMode]), ChannelMonitorDefaultIntervalSeconds: parseChannelMonitorInterval(settings[SettingKeyChannelMonitorDefaultIntervalSeconds]), + ChannelMonitorHideThroughput: !isFalseSettingValue(settings[SettingKeyChannelMonitorHideThroughput]), AvailableChannelsEnabled: settings[SettingKeyAvailableChannelsEnabled] == "true", @@ -369,8 +373,21 @@ const ( channelMonitorIntervalMin = 15 channelMonitorIntervalMax = 3600 channelMonitorIntervalFallback = 60 + defaultChannelMonitorMode = ChannelMonitorModeV1 ) +// normalizeChannelMonitorMode accepts only v1/v2; empty/invalid → v1 (safe default). +func normalizeChannelMonitorMode(raw string) string { + switch strings.ToLower(strings.TrimSpace(raw)) { + case ChannelMonitorModeV1, "": + return ChannelMonitorModeV1 + case ChannelMonitorModeV2: + return ChannelMonitorModeV2 + default: + return defaultChannelMonitorMode + } +} + // parseChannelMonitorInterval parses the stored string and clamps to [15, 3600]. // Empty / invalid input falls back to channelMonitorIntervalFallback. func parseChannelMonitorInterval(raw string) int { @@ -396,25 +413,55 @@ func clampChannelMonitorInterval(v int) int { } // ChannelMonitorRuntime is the lightweight view of the channel monitor feature -// consumed by the runner and user-facing handlers. +// consumed by the runner, V2 aggregator, and user-facing handlers. type ChannelMonitorRuntime struct { Enabled bool + Mode string // ChannelMonitorModeV1 or ChannelMonitorModeV2 DefaultIntervalSeconds int + // HideThroughput: when true, user-facing V2 APIs omit RPM/TPM scale signals. + HideThroughput bool +} + +// ActiveProbesAllowed reports whether V1 active provider probes may run. +func (r ChannelMonitorRuntime) ActiveProbesAllowed() bool { + return r.Enabled && r.Mode == ChannelMonitorModeV1 +} + +// PassiveAggregationAllowed reports whether V2 passive aggregation may run. +func (r ChannelMonitorRuntime) PassiveAggregationAllowed() bool { + return r.Enabled && r.Mode == ChannelMonitorModeV2 } // GetChannelMonitorRuntime reads the channel monitor feature flags directly from -// the settings store. Fail-open: on error returns Enabled=true with the default interval. +// the settings store. Fail-open: on error returns Enabled=true, Mode=v1, default interval. func (s *SettingService) GetChannelMonitorRuntime(ctx context.Context) ChannelMonitorRuntime { + if s == nil || s.settingRepo == nil { + return ChannelMonitorRuntime{ + Enabled: true, + Mode: defaultChannelMonitorMode, + DefaultIntervalSeconds: channelMonitorIntervalFallback, + HideThroughput: true, + } + } vals, err := s.settingRepo.GetMultiple(ctx, []string{ SettingKeyChannelMonitorEnabled, + SettingKeyChannelMonitorMode, SettingKeyChannelMonitorDefaultIntervalSeconds, + SettingKeyChannelMonitorHideThroughput, }) if err != nil { - return ChannelMonitorRuntime{Enabled: true, DefaultIntervalSeconds: channelMonitorIntervalFallback} + return ChannelMonitorRuntime{ + Enabled: true, + Mode: defaultChannelMonitorMode, + DefaultIntervalSeconds: channelMonitorIntervalFallback, + HideThroughput: true, + } } return ChannelMonitorRuntime{ Enabled: !isFalseSettingValue(vals[SettingKeyChannelMonitorEnabled]), + Mode: normalizeChannelMonitorMode(vals[SettingKeyChannelMonitorMode]), DefaultIntervalSeconds: parseChannelMonitorInterval(vals[SettingKeyChannelMonitorDefaultIntervalSeconds]), + HideThroughput: !isFalseSettingValue(vals[SettingKeyChannelMonitorHideThroughput]), } } @@ -550,14 +597,18 @@ type PublicSettingsInjectionPayload struct { // Feature flags — MUST match the opt-in/opt-out registry in // frontend/src/utils/featureFlags.ts. Missing a field here is the bug // that hid the "可用渠道" menu on page refresh. - ChannelMonitorEnabled bool `json:"channel_monitor_enabled"` - ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"` - AvailableChannelsEnabled bool `json:"available_channels_enabled"` - ModelPlazaEnabled bool `json:"model_plaza_enabled"` - ModelPlazaRequireAuth bool `json:"model_plaza_require_auth"` - AffiliateEnabled bool `json:"affiliate_enabled"` - RiskControlEnabled bool `json:"risk_control_enabled"` - AllowUserViewErrorRequests bool `json:"allow_user_view_error_requests"` + ChannelMonitorEnabled bool `json:"channel_monitor_enabled"` + ChannelMonitorMode string `json:"channel_monitor_mode"` + ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"` + // ChannelMonitorHideThroughput is public so the user UI can hide RPM/TPM + // without waiting for API redaction alone (defense in depth). + ChannelMonitorHideThroughput bool `json:"channel_monitor_hide_throughput"` + AvailableChannelsEnabled bool `json:"available_channels_enabled"` + ModelPlazaEnabled bool `json:"model_plaza_enabled"` + ModelPlazaRequireAuth bool `json:"model_plaza_require_auth"` + AffiliateEnabled bool `json:"affiliate_enabled"` + RiskControlEnabled bool `json:"risk_control_enabled"` + AllowUserViewErrorRequests bool `json:"allow_user_view_error_requests"` } // GetPublicSettingsForInjection returns public settings in a format suitable for HTML injection. @@ -627,7 +678,9 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any BalanceLowNotifyRechargeURL: settings.BalanceLowNotifyRechargeURL, ChannelMonitorEnabled: settings.ChannelMonitorEnabled, + ChannelMonitorMode: settings.ChannelMonitorMode, ChannelMonitorDefaultIntervalSeconds: settings.ChannelMonitorDefaultIntervalSeconds, + ChannelMonitorHideThroughput: settings.ChannelMonitorHideThroughput, AvailableChannelsEnabled: settings.AvailableChannelsEnabled, ModelPlazaEnabled: settings.ModelPlazaEnabled, ModelPlazaRequireAuth: settings.ModelPlazaRequireAuth, diff --git a/backend/internal/service/setting_service.go b/backend/internal/service/setting_service.go index 73bde4d30..f9091e8a2 100644 --- a/backend/internal/service/setting_service.go +++ b/backend/internal/service/setting_service.go @@ -12,6 +12,7 @@ import ( infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" "github.com/Wei-Shaw/sub2api/internal/pkg/xai" "golang.org/x/sync/singleflight" + "sync" ) const ( @@ -148,6 +149,9 @@ type SettingService struct { // instance owns its own cache, no shared package-level state. openAIQuotaAutoPauseSettingsCache atomic.Value // *cachedOpenAIQuotaAutoPauseSettings openAIQuotaAutoPauseSettingsSF singleflight.Group + + channelMonitorRuntimeListenersMu sync.Mutex + channelMonitorRuntimeListeners []func() } // DefaultPlatformQuotaSetting 单 platform 三档限额(nil = 沿用上层;0 = 显式禁用;>0 = 上限) @@ -372,6 +376,52 @@ func (s *SettingService) SetOnUpdateCallback(callback func()) { s.onUpdate = callback } +// SubscribeChannelMonitorRuntime registers a listener that is invoked after +// settings are successfully persisted (and process caches refreshed). +// Used by ChannelMonitorRunner / ChannelMonitorV2Aggregator for immediate +// mode flips without waiting for poll intervals. +func (s *SettingService) SubscribeChannelMonitorRuntime(listener func()) (unsubscribe func()) { + if s == nil || listener == nil { + return func() {} + } + s.channelMonitorRuntimeListenersMu.Lock() + s.channelMonitorRuntimeListeners = append(s.channelMonitorRuntimeListeners, listener) + idx := len(s.channelMonitorRuntimeListeners) - 1 + s.channelMonitorRuntimeListenersMu.Unlock() + return func() { + s.channelMonitorRuntimeListenersMu.Lock() + defer s.channelMonitorRuntimeListenersMu.Unlock() + if idx < 0 || idx >= len(s.channelMonitorRuntimeListeners) { + return + } + s.channelMonitorRuntimeListeners[idx] = nil + } +} + +func (s *SettingService) notifyChannelMonitorRuntimeListeners() { + if s == nil { + return + } + s.channelMonitorRuntimeListenersMu.Lock() + listeners := make([]func(), 0, len(s.channelMonitorRuntimeListeners)) + for _, l := range s.channelMonitorRuntimeListeners { + if l != nil { + listeners = append(listeners, l) + } + } + s.channelMonitorRuntimeListenersMu.Unlock() + for _, l := range listeners { + func(fn func()) { + defer func() { + if recovered := recover(); recovered != nil { + _ = recovered // keep settings path healthy + } + }() + fn() + }(l) + } +} + // SetVersion sets the application version for injection into public settings func (s *SettingService) SetVersion(version string) { s.version = version diff --git a/backend/internal/service/setting_service_public_test.go b/backend/internal/service/setting_service_public_test.go index 1b63d412f..67bab243c 100644 --- a/backend/internal/service/setting_service_public_test.go +++ b/backend/internal/service/setting_service_public_test.go @@ -101,6 +101,21 @@ func TestSettingService_GetPublicSettings_ExposesCompactHomeEnabled(t *testing.T require.False(t, missingSettings.CompactHomeEnabled) } +func TestSettingService_ChannelMonitorHideThroughputDefaultsToPrivate(t *testing.T) { + missing := NewSettingService(&settingPublicRepoStub{values: map[string]string{}}, &config.Config{}).GetChannelMonitorRuntime(context.Background()) + require.True(t, missing.HideThroughput) + public, err := NewSettingService(&settingPublicRepoStub{values: map[string]string{}}, &config.Config{}).GetPublicSettings(context.Background()) + require.NoError(t, err) + require.True(t, public.ChannelMonitorHideThroughput) + + for _, value := range []string{"false", "0", "off", "disabled"} { + runtime := NewSettingService(&settingPublicRepoStub{values: map[string]string{ + SettingKeyChannelMonitorHideThroughput: value, + }}, &config.Config{}).GetChannelMonitorRuntime(context.Background()) + require.False(t, runtime.HideThroughput, "value=%q", value) + } +} + func TestSettingService_GetPublicSettings_ExposesForceEmailOnThirdPartySignup(t *testing.T) { repo := &settingPublicRepoStub{ values: map[string]string{ diff --git a/backend/internal/service/setting_update.go b/backend/internal/service/setting_update.go index 637e609b0..1d40f20ca 100644 --- a/backend/internal/service/setting_update.go +++ b/backend/internal/service/setting_update.go @@ -411,9 +411,11 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting // Channel monitor feature switch updates[SettingKeyChannelMonitorEnabled] = strconv.FormatBool(settings.ChannelMonitorEnabled) + updates[SettingKeyChannelMonitorMode] = normalizeChannelMonitorMode(settings.ChannelMonitorMode) if v := clampChannelMonitorInterval(settings.ChannelMonitorDefaultIntervalSeconds); v > 0 { updates[SettingKeyChannelMonitorDefaultIntervalSeconds] = strconv.Itoa(v) } + updates[SettingKeyChannelMonitorHideThroughput] = strconv.FormatBool(settings.ChannelMonitorHideThroughput) // Grok model mapping policy if v := strings.TrimSpace(settings.GrokDefaultTextModel); v != "" { @@ -780,6 +782,7 @@ func (s *SettingService) refreshCachedSettings(settings *SystemSettings) { if s.onUpdate != nil { s.onUpdate() // Invalidate cache after settings update } + s.notifyChannelMonitorRuntimeListeners() } func (s *SettingService) defaultRewriteMessageCacheControl() bool { diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go index 2b673a359..3ccdd0239 100644 --- a/backend/internal/service/settings_view.go +++ b/backend/internal/service/settings_view.go @@ -196,8 +196,10 @@ type SystemSettings struct { OpsMetricsIntervalSeconds int // Channel Monitor feature - ChannelMonitorEnabled bool `json:"channel_monitor_enabled"` - ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"` + ChannelMonitorEnabled bool `json:"channel_monitor_enabled"` + ChannelMonitorMode string `json:"channel_monitor_mode"` + ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"` + ChannelMonitorHideThroughput bool `json:"channel_monitor_hide_throughput"` // Grok model mapping policy (admin settings; empty mapping falls back to these). GrokDefaultTextModel string `json:"grok_default_text_model"` @@ -370,8 +372,10 @@ type PublicSettings struct { BalanceLowNotifyRechargeURL string // Channel Monitor feature - ChannelMonitorEnabled bool `json:"channel_monitor_enabled"` - ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"` + ChannelMonitorEnabled bool `json:"channel_monitor_enabled"` + ChannelMonitorMode string `json:"channel_monitor_mode"` + ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"` + ChannelMonitorHideThroughput bool `json:"channel_monitor_hide_throughput"` // Grok model mapping policy (admin settings). GrokDefaultTextModel string `json:"grok_default_text_model"` diff --git a/backend/internal/service/wire.go b/backend/internal/service/wire.go index 9e12ae0be..93555730a 100644 --- a/backend/internal/service/wire.go +++ b/backend/internal/service/wire.go @@ -3,6 +3,7 @@ package service import ( "context" "database/sql" + "os" "time" dbent "github.com/Wei-Shaw/sub2api/ent" @@ -860,6 +861,8 @@ var ProviderSet = wire.NewSet( ProvideBalanceNotifyService, ProvideChannelMonitorService, ProvideChannelMonitorRunner, + ProvideChannelMonitorV2Service, + ProvideChannelMonitorV2Aggregator, NewChannelMonitorRequestTemplateService, ProvideUserPlatformQuotaUsageFlusher, ) @@ -901,11 +904,15 @@ func ProvidePaymentOrderExpiryService(paymentSvc *PaymentService, lockCache Lead // ProvideChannelMonitorService 创建渠道监控服务(CRUD + RunCheck + 用户视图聚合)。 // 加密器复用 wire 中已注入的 SecretEncryptor(AES-256-GCM)。 +// settingService gates RunCheck via channel_monitor_enabled + channel_monitor_mode. func ProvideChannelMonitorService( repo ChannelMonitorRepository, encryptor SecretEncryptor, + settingService *SettingService, ) *ChannelMonitorService { - return NewChannelMonitorService(repo, encryptor) + svc := NewChannelMonitorService(repo, encryptor) + svc.SetRuntimeReader(settingService) + return svc } // ProvideChannelMonitorRunner 创建并启动渠道监控调度器。 @@ -914,7 +921,32 @@ func ProvideChannelMonitorService( // settingService 用于 runner 每次 fire 读取功能开关。 func ProvideChannelMonitorRunner(svc *ChannelMonitorService, settingService *SettingService) *ChannelMonitorRunner { r := NewChannelMonitorRunner(svc, settingService) - svc.SetScheduler(r) + if svc != nil { + // Ensure runtime reader is set even if ProvideChannelMonitorService + // was constructed without settings (tests / alternate providers). + svc.SetRuntimeReader(settingService) + svc.SetScheduler(r) + } r.Start() return r } + +// ProvideChannelMonitorV2Service wires settings for user-facing privacy flags +// (e.g. hide RPM/TPM throughput). +func ProvideChannelMonitorV2Service(repo ChannelMonitorV2Repository, settingService *SettingService) *ChannelMonitorV2Service { + svc := NewChannelMonitorV2Service(repo) + svc.SetRuntimeReader(settingService) + return svc +} + +// ProvideChannelMonitorV2Aggregator starts the passive minute-rollup worker. +// Aggregation only runs when channel_monitor_enabled=true and mode=v2 (and V2 config enabled). +// Set CHANNEL_MONITOR_V2_DISABLE_AGGREGATOR=1 to skip Start (local demo with seeded facts). +func ProvideChannelMonitorV2Aggregator(repo ChannelMonitorV2Repository, db *sql.DB, settingService *SettingService) *ChannelMonitorV2Aggregator { + aggregator := NewChannelMonitorV2Aggregator(repo, db, settingService) + if os.Getenv("CHANNEL_MONITOR_V2_DISABLE_AGGREGATOR") == "1" { + return aggregator + } + aggregator.Start() + return aggregator +} diff --git a/backend/migrations/194_channel_monitor_v2.sql b/backend/migrations/194_channel_monitor_v2.sql new file mode 100644 index 000000000..4e18ab152 --- /dev/null +++ b/backend/migrations/194_channel_monitor_v2.sql @@ -0,0 +1,119 @@ +-- Passive channel monitor V2. This migration creates empty rollup tables only; +-- backfill is intentionally handled by a bounded background worker. + +CREATE TABLE IF NOT EXISTS channel_monitor_v2_config ( + id SMALLINT PRIMARY KEY DEFAULT 1 CHECK (id = 1), + version INTEGER NOT NULL DEFAULT 1, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + refresh_interval_seconds INTEGER NOT NULL DEFAULT 60 + CHECK (refresh_interval_seconds IN (60, 300)), + platforms JSONB NOT NULL DEFAULT '[{"platform":"anthropic","enabled":true,"models":[]},{"platform":"openai","enabled":true,"models":[]},{"platform":"grok","enabled":true,"models":[]},{"platform":"kiro","enabled":true,"models":[]},{"platform":"gemini","enabled":true,"models":[]},{"platform":"antigravity","enabled":true,"models":[]}]'::jsonb, + group_ids BIGINT[] NOT NULL DEFAULT '{}', + updated_by BIGINT, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +INSERT INTO channel_monitor_v2_config (id) VALUES (1) +ON CONFLICT (id) DO NOTHING; + +CREATE TABLE IF NOT EXISTS channel_monitor_v2_metrics_1m ( + bucket_start TIMESTAMPTZ NOT NULL, + platform TEXT NOT NULL, + group_id BIGINT NOT NULL DEFAULT 0, + model TEXT NOT NULL, + success_requests BIGINT NOT NULL DEFAULT 0, + error_requests BIGINT NOT NULL DEFAULT 0, + upstream_affected_requests BIGINT NOT NULL DEFAULT 0, + upstream_attempt_count BIGINT NOT NULL DEFAULT 0, + input_tokens BIGINT NOT NULL DEFAULT 0, + output_tokens BIGINT NOT NULL DEFAULT 0, + cache_creation_tokens BIGINT NOT NULL DEFAULT 0, + cache_read_tokens BIGINT NOT NULL DEFAULT 0, + ttft_sum_ms BIGINT NOT NULL DEFAULT 0, + ttft_count BIGINT NOT NULL DEFAULT 0, + duration_sum_ms BIGINT NOT NULL DEFAULT 0, + duration_count BIGINT NOT NULL DEFAULT 0, + computed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (bucket_start, platform, group_id, model) +); + +CREATE INDEX IF NOT EXISTS idx_channel_monitor_v2_metrics_platform_time + ON channel_monitor_v2_metrics_1m (platform, bucket_start DESC); +CREATE INDEX IF NOT EXISTS idx_channel_monitor_v2_metrics_group_time + ON channel_monitor_v2_metrics_1m (group_id, bucket_start DESC); +CREATE INDEX IF NOT EXISTS idx_channel_monitor_v2_metrics_model_time + ON channel_monitor_v2_metrics_1m (model, bucket_start DESC); + +CREATE TABLE IF NOT EXISTS channel_monitor_v2_user_metrics_1m ( + bucket_start TIMESTAMPTZ NOT NULL, + platform TEXT NOT NULL, + group_id BIGINT NOT NULL DEFAULT 0, + model TEXT NOT NULL, + user_id BIGINT NOT NULL, + success_requests BIGINT NOT NULL DEFAULT 0, + error_requests BIGINT NOT NULL DEFAULT 0, + input_tokens BIGINT NOT NULL DEFAULT 0, + output_tokens BIGINT NOT NULL DEFAULT 0, + cache_creation_tokens BIGINT NOT NULL DEFAULT 0, + cache_read_tokens BIGINT NOT NULL DEFAULT 0, + ttft_sum_ms BIGINT NOT NULL DEFAULT 0, + ttft_count BIGINT NOT NULL DEFAULT 0, + duration_sum_ms BIGINT NOT NULL DEFAULT 0, + duration_count BIGINT NOT NULL DEFAULT 0, + computed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (bucket_start, platform, group_id, model, user_id) +); + +CREATE INDEX IF NOT EXISTS idx_channel_monitor_v2_user_metrics_user_time + ON channel_monitor_v2_user_metrics_1m (user_id, bucket_start DESC); +CREATE INDEX IF NOT EXISTS idx_channel_monitor_v2_user_metrics_time + ON channel_monitor_v2_user_metrics_1m (bucket_start DESC); + +CREATE TABLE IF NOT EXISTS channel_monitor_v2_error_metrics_1m ( + bucket_start TIMESTAMPTZ NOT NULL, + platform TEXT NOT NULL, + group_id BIGINT NOT NULL DEFAULT 0, + model TEXT NOT NULL, + error_category TEXT NOT NULL, + taxonomy_version SMALLINT NOT NULL, + error_requests BIGINT NOT NULL DEFAULT 0, + PRIMARY KEY (bucket_start, platform, group_id, model, error_category, taxonomy_version) +); + +CREATE INDEX IF NOT EXISTS idx_channel_monitor_v2_errors_time + ON channel_monitor_v2_error_metrics_1m (bucket_start DESC); +CREATE INDEX IF NOT EXISTS idx_channel_monitor_v2_errors_category_time + ON channel_monitor_v2_error_metrics_1m (error_category, bucket_start DESC); + +CREATE TABLE IF NOT EXISTS channel_monitor_v2_latency_histograms_1m ( + bucket_start TIMESTAMPTZ NOT NULL, + platform TEXT NOT NULL, + group_id BIGINT NOT NULL DEFAULT 0, + model TEXT NOT NULL, + user_id BIGINT NOT NULL DEFAULT 0, + metric TEXT NOT NULL CHECK (metric IN ('ttft', 'duration')), + upper_bound_ms INTEGER NOT NULL, + sample_count BIGINT NOT NULL DEFAULT 0, + PRIMARY KEY (bucket_start, platform, group_id, model, user_id, metric, upper_bound_ms) +); + +CREATE INDEX IF NOT EXISTS idx_channel_monitor_v2_histograms_time + ON channel_monitor_v2_latency_histograms_1m (bucket_start DESC, metric); + +CREATE TABLE IF NOT EXISTS channel_monitor_v2_watermarks ( + id SMALLINT PRIMARY KEY DEFAULT 1 CHECK (id = 1), + usage_coverage_start TIMESTAMPTZ, + error_coverage_start TIMESTAMPTZ, + data_through TIMESTAMPTZ, + last_successful_at TIMESTAMPTZ, + backfill_cursor TIMESTAMPTZ, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +INSERT INTO channel_monitor_v2_watermarks (id) VALUES (1) +ON CONFLICT (id) DO NOTHING; + +COMMENT ON TABLE channel_monitor_v2_metrics_1m IS + 'One-minute passive channel health facts derived from real user requests; never from active probes.'; +COMMENT ON COLUMN channel_monitor_v2_error_metrics_1m.taxonomy_version IS + 'Version of the ordered error classification rules used for this aggregate.'; diff --git a/backend/migrations/195_channel_monitor_mode.sql b/backend/migrations/195_channel_monitor_mode.sql new file mode 100644 index 000000000..ba3d2f95d --- /dev/null +++ b/backend/migrations/195_channel_monitor_mode.sql @@ -0,0 +1,5 @@ +-- Channel monitor exclusive mode: v1 (active probes) or v2 (passive aggregation). +-- Default v1 preserves existing active-probe behavior; operators opt in to v2. +INSERT INTO settings (key, value) +VALUES ('channel_monitor_mode', 'v1') +ON CONFLICT (key) DO NOTHING; diff --git a/backend/migrations/196_channel_monitor_v2_ignored_error_categories.sql b/backend/migrations/196_channel_monitor_v2_ignored_error_categories.sql new file mode 100644 index 000000000..c3e65f26a --- /dev/null +++ b/backend/migrations/196_channel_monitor_v2_ignored_error_categories.sql @@ -0,0 +1,3 @@ +-- Categories excluded from error_rate / health scoring (still shown in error breakdown). +ALTER TABLE channel_monitor_v2_config + ADD COLUMN IF NOT EXISTS ignored_error_categories TEXT[] NOT NULL DEFAULT '{}'; diff --git a/backend/migrations/197_channel_monitor_v2_seed_popular_models.sql b/backend/migrations/197_channel_monitor_v2_seed_popular_models.sql new file mode 100644 index 000000000..248f2afbf --- /dev/null +++ b/backend/migrations/197_channel_monitor_v2_seed_popular_models.sql @@ -0,0 +1,139 @@ +-- Seed factory channel_monitor_v2_config.platforms with a popular-model allow-list +-- (high-traffic names from production usage). Presentation semantics: +-- - listed models → own dimension +-- - unlisted models → __other__ +-- - empty models list (operator cleared) → show every real model name (no collapse) +-- Aggregation always stores the real model name; this only affects presentation. +-- +-- Only rewrite rows whose platforms still look like the empty-models factory +-- default (every platform has models: []). Custom operator configs are left alone. + +UPDATE channel_monitor_v2_config +SET + platforms = $platforms$ +[ + { + "platform": "anthropic", + "enabled": true, + "models": [ + "claude-opus-5", + "claude-opus-4-8", + "claude-opus-4-7", + "claude-opus-4-6", + "claude-opus-4.6", + "claude-opus-4-5", + "claude-opus-4-1", + "claude-opus-4", + "claude-fable-5", + "claude-sonnet-5", + "claude-sonnet-4-6", + "claude-sonnet-4.6", + "claude-sonnet-4-5", + "claude-sonnet-4.5", + "claude-sonnet-4", + "claude-haiku-4-5", + "claude-haiku-4.5", + "claude-3-7-sonnet", + "claude-3-5-sonnet", + "claude-3-5-haiku" + ] + }, + { + "platform": "openai", + "enabled": true, + "models": [ + "gpt-5.6-sol", + "gpt-5.6-terra", + "gpt-5.6-luna", + "gpt-5.6", + "gpt-5.5", + "gpt-5.4", + "gpt-5.4-mini", + "gpt-5.3-codex-spark", + "gpt-5.2", + "gpt-5.2-pro", + "gpt-5", + "gpt-4.1", + "gpt-4.1-mini", + "gpt-4.1-nano", + "gpt-4o", + "gpt-4o-mini", + "gpt-4-turbo", + "gpt-4", + "o3", + "o3-mini", + "o4-mini", + "codex-auto-review", + "gpt-image-2", + "gpt-image-1" + ] + }, + { + "platform": "grok", + "enabled": true, + "models": [ + "grok-4.5", + "grok-4.5-latest", + "grok-4.3", + "grok-4.20-multi-agent", + "grok-4.20-0309-reasoning", + "grok-4.20-0309-non-reasoning", + "grok-build-0.1", + "grok-build", + "grok-3-mini", + "grok-3-mini-fast", + "grok-code-fast-1", + "grok-code-fast" + ] + }, + { + "platform": "gemini", + "enabled": true, + "models": [ + "gemini-2.5-pro", + "gemini-2.5-flash", + "gemini-2.5-flash-lite", + "gemini-2.5-flash-image", + "gemini-2.0-flash", + "gemini-2.0-flash-lite", + "gemini-3-flash", + "gemini-3-pro-image", + "gemini-3.1-pro-high", + "gemini-3.1-pro-low", + "gemini-3.1-flash-lite", + "gemini-3.5-flash", + "gemini-3.5-flash-lite" + ] + }, + { + "platform": "kiro", + "enabled": true, + "models": [ + "claude-opus-4-8", + "claude-opus-4-6", + "claude-opus-4-5", + "claude-sonnet-4-5", + "claude-sonnet-4-6", + "claude-haiku-4-5" + ] + }, + { + "platform": "antigravity", + "enabled": true, + "models": [ + "claude-opus-4-6", + "claude-sonnet-4-5", + "claude-sonnet-4-6", + "gemini-2.5-pro", + "gemini-2.5-flash" + ] + } +] +$platforms$::jsonb, + version = version + 1, + updated_at = NOW() +WHERE id = 1 + -- Only seed the exact factory default from migration 194. A row with custom + -- enabled=false, platform set/order, or any other operator tweak must be left + -- untouched even if every models list is still empty. + AND platforms = '[{"platform":"anthropic","enabled":true,"models":[]},{"platform":"openai","enabled":true,"models":[]},{"platform":"grok","enabled":true,"models":[]},{"platform":"kiro","enabled":true,"models":[]},{"platform":"gemini","enabled":true,"models":[]},{"platform":"antigravity","enabled":true,"models":[]}]'::jsonb; diff --git a/backend/migrations/198_channel_monitor_v2_health_thresholds.sql b/backend/migrations/198_channel_monitor_v2_health_thresholds.sql new file mode 100644 index 000000000..dad533104 --- /dev/null +++ b/backend/migrations/198_channel_monitor_v2_health_thresholds.sql @@ -0,0 +1,33 @@ +-- Configurable Channel Monitor V2 health scoring thresholds. +-- Defaults are intentionally tolerant: small amounts of real traffic errors or +-- zero cache rate should not immediately paint the user page as "abnormal". +ALTER TABLE channel_monitor_v2_config + ADD COLUMN IF NOT EXISTS health_thresholds JSONB NOT NULL DEFAULT '{ + "minimum_sample": 50, + "warning_error_rate": 0.05, + "critical_error_rate": 0.20, + "target_ttft_ms": 3000, + "warning_ttft_ms": 8000, + "critical_ttft_ms": 20000, + "warning_cache_rate": 0, + "critical_cache_rate": 0, + "error_weight": 0.60, + "ttft_weight": 0.20, + "cache_weight": 0.20 + }'::jsonb; + +UPDATE channel_monitor_v2_config +SET health_thresholds = '{ + "minimum_sample": 50, + "warning_error_rate": 0.05, + "critical_error_rate": 0.20, + "target_ttft_ms": 3000, + "warning_ttft_ms": 8000, + "critical_ttft_ms": 20000, + "warning_cache_rate": 0, + "critical_cache_rate": 0, + "error_weight": 0.60, + "ttft_weight": 0.20, + "cache_weight": 0.20 + }'::jsonb || health_thresholds +WHERE id = 1; diff --git a/backend/migrations/199_channel_monitor_v2_fixed_rollups.sql b/backend/migrations/199_channel_monitor_v2_fixed_rollups.sql new file mode 100644 index 000000000..f033401e0 --- /dev/null +++ b/backend/migrations/199_channel_monitor_v2_fixed_rollups.sql @@ -0,0 +1,95 @@ +-- Fixed-size Channel Monitor V2 rollups for the 24h / 7d / 30d views. +-- Source of truth remains the 1-minute passive rollup; these tables cache the +-- UI bucket sizes so historical buckets do not need to be re-aggregated on each +-- filter change. The background aggregator overwrites affected buckets. + +CREATE TABLE IF NOT EXISTS channel_monitor_v2_metrics_rollup ( + bucket_start TIMESTAMPTZ NOT NULL, + bucket_seconds INTEGER NOT NULL CHECK (bucket_seconds IN (300, 3600, 43200, 86400)), + platform TEXT NOT NULL, + group_id BIGINT NOT NULL DEFAULT 0, + model TEXT NOT NULL, + success_requests BIGINT NOT NULL DEFAULT 0, + error_requests BIGINT NOT NULL DEFAULT 0, + upstream_affected_requests BIGINT NOT NULL DEFAULT 0, + upstream_attempt_count BIGINT NOT NULL DEFAULT 0, + input_tokens BIGINT NOT NULL DEFAULT 0, + output_tokens BIGINT NOT NULL DEFAULT 0, + cache_creation_tokens BIGINT NOT NULL DEFAULT 0, + cache_read_tokens BIGINT NOT NULL DEFAULT 0, + ttft_sum_ms BIGINT NOT NULL DEFAULT 0, + ttft_count BIGINT NOT NULL DEFAULT 0, + duration_sum_ms BIGINT NOT NULL DEFAULT 0, + duration_count BIGINT NOT NULL DEFAULT 0, + computed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (bucket_seconds, bucket_start, platform, group_id, model) +); + +CREATE INDEX IF NOT EXISTS idx_channel_monitor_v2_metrics_rollup_platform_time + ON channel_monitor_v2_metrics_rollup (bucket_seconds, platform, bucket_start DESC); +CREATE INDEX IF NOT EXISTS idx_channel_monitor_v2_metrics_rollup_group_time + ON channel_monitor_v2_metrics_rollup (bucket_seconds, group_id, bucket_start DESC); +CREATE INDEX IF NOT EXISTS idx_channel_monitor_v2_metrics_rollup_model_time + ON channel_monitor_v2_metrics_rollup (bucket_seconds, model, bucket_start DESC); + +CREATE TABLE IF NOT EXISTS channel_monitor_v2_user_metrics_rollup ( + bucket_start TIMESTAMPTZ NOT NULL, + bucket_seconds INTEGER NOT NULL CHECK (bucket_seconds IN (300, 3600, 43200, 86400)), + platform TEXT NOT NULL, + group_id BIGINT NOT NULL DEFAULT 0, + model TEXT NOT NULL, + user_id BIGINT NOT NULL, + success_requests BIGINT NOT NULL DEFAULT 0, + error_requests BIGINT NOT NULL DEFAULT 0, + input_tokens BIGINT NOT NULL DEFAULT 0, + output_tokens BIGINT NOT NULL DEFAULT 0, + cache_creation_tokens BIGINT NOT NULL DEFAULT 0, + cache_read_tokens BIGINT NOT NULL DEFAULT 0, + ttft_sum_ms BIGINT NOT NULL DEFAULT 0, + ttft_count BIGINT NOT NULL DEFAULT 0, + duration_sum_ms BIGINT NOT NULL DEFAULT 0, + duration_count BIGINT NOT NULL DEFAULT 0, + computed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (bucket_seconds, bucket_start, platform, group_id, model, user_id) +); + +CREATE INDEX IF NOT EXISTS idx_channel_monitor_v2_user_rollup_user_time + ON channel_monitor_v2_user_metrics_rollup (bucket_seconds, user_id, bucket_start DESC); +CREATE INDEX IF NOT EXISTS idx_channel_monitor_v2_user_rollup_time + ON channel_monitor_v2_user_metrics_rollup (bucket_seconds, bucket_start DESC); + +CREATE TABLE IF NOT EXISTS channel_monitor_v2_error_metrics_rollup ( + bucket_start TIMESTAMPTZ NOT NULL, + bucket_seconds INTEGER NOT NULL CHECK (bucket_seconds IN (300, 3600, 43200, 86400)), + platform TEXT NOT NULL, + group_id BIGINT NOT NULL DEFAULT 0, + model TEXT NOT NULL, + error_category TEXT NOT NULL, + taxonomy_version SMALLINT NOT NULL, + error_requests BIGINT NOT NULL DEFAULT 0, + PRIMARY KEY (bucket_seconds, bucket_start, platform, group_id, model, error_category, taxonomy_version) +); + +CREATE INDEX IF NOT EXISTS idx_channel_monitor_v2_errors_rollup_time + ON channel_monitor_v2_error_metrics_rollup (bucket_seconds, bucket_start DESC); +CREATE INDEX IF NOT EXISTS idx_channel_monitor_v2_errors_rollup_category_time + ON channel_monitor_v2_error_metrics_rollup (bucket_seconds, error_category, bucket_start DESC); + +CREATE TABLE IF NOT EXISTS channel_monitor_v2_latency_histograms_rollup ( + bucket_start TIMESTAMPTZ NOT NULL, + bucket_seconds INTEGER NOT NULL CHECK (bucket_seconds IN (300, 3600, 43200, 86400)), + platform TEXT NOT NULL, + group_id BIGINT NOT NULL DEFAULT 0, + model TEXT NOT NULL, + user_id BIGINT NOT NULL DEFAULT 0, + metric TEXT NOT NULL CHECK (metric IN ('ttft', 'duration')), + upper_bound_ms INTEGER NOT NULL, + sample_count BIGINT NOT NULL DEFAULT 0, + PRIMARY KEY (bucket_seconds, bucket_start, platform, group_id, model, user_id, metric, upper_bound_ms) +); + +CREATE INDEX IF NOT EXISTS idx_channel_monitor_v2_histograms_rollup_time + ON channel_monitor_v2_latency_histograms_rollup (bucket_seconds, bucket_start DESC, metric); + +COMMENT ON TABLE channel_monitor_v2_metrics_rollup IS + 'Precomputed fixed UI buckets derived from channel_monitor_v2_metrics_1m for 24h/7d/30d views.'; diff --git a/backend/migrations/200_channel_monitor_v2_rollup_permissions.sql b/backend/migrations/200_channel_monitor_v2_rollup_permissions.sql new file mode 100644 index 000000000..26cc54f09 --- /dev/null +++ b/backend/migrations/200_channel_monitor_v2_rollup_permissions.sql @@ -0,0 +1,9 @@ +-- Runtime aggregator reads/writes the fixed rollup caches through the role that +-- executes migrations. Use CURRENT_USER so custom DATABASE_USER values work; +-- PostgreSQL permits this form in GRANT and it remains idempotent. +GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE + channel_monitor_v2_metrics_rollup, + channel_monitor_v2_user_metrics_rollup, + channel_monitor_v2_error_metrics_rollup, + channel_monitor_v2_latency_histograms_rollup +TO CURRENT_USER; diff --git a/backend/migrations/201_channel_monitor_v2_refresh_5m.sql b/backend/migrations/201_channel_monitor_v2_refresh_5m.sql new file mode 100644 index 000000000..7bd67e4a9 --- /dev/null +++ b/backend/migrations/201_channel_monitor_v2_refresh_5m.sql @@ -0,0 +1,12 @@ +-- Channel Monitor V2 is designed around fixed UI buckets; refresh passive +-- aggregation every 5 minutes and recompute the trailing bucket for each range. +-- Only migrate the untouched factory row. A row with a newer version or an +-- updater identity reflects an operator choice and must retain its 60-second +-- interval (or any other valid setting). +UPDATE channel_monitor_v2_config +SET refresh_interval_seconds = 300, + updated_at = NOW() +WHERE id = 1 + AND version = 1 + AND updated_by IS NULL + AND refresh_interval_seconds = 60; diff --git a/backend/migrations/202_channel_monitor_v2_full_table_permissions.sql b/backend/migrations/202_channel_monitor_v2_full_table_permissions.sql new file mode 100644 index 000000000..a19b8722d --- /dev/null +++ b/backend/migrations/202_channel_monitor_v2_full_table_permissions.sql @@ -0,0 +1,15 @@ +-- Extend privileges for all Channel Monitor V2 tables (base + rollup + config). +-- Grant to the migration/application role rather than the historical hard-coded +-- "sub2api" role so custom DATABASE_USER deployments work as expected. +GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE + channel_monitor_v2_config, + channel_monitor_v2_watermarks, + channel_monitor_v2_metrics_1m, + channel_monitor_v2_user_metrics_1m, + channel_monitor_v2_error_metrics_1m, + channel_monitor_v2_latency_histograms_1m, + channel_monitor_v2_metrics_rollup, + channel_monitor_v2_user_metrics_rollup, + channel_monitor_v2_error_metrics_rollup, + channel_monitor_v2_latency_histograms_rollup +TO CURRENT_USER; diff --git a/backend/migrations/203_channel_monitor_v2_default_ignore_and_cache.sql b/backend/migrations/203_channel_monitor_v2_default_ignore_and_cache.sql new file mode 100644 index 000000000..4a03f0054 --- /dev/null +++ b/backend/migrations/203_channel_monitor_v2_default_ignore_and_cache.sql @@ -0,0 +1,31 @@ +-- Factory presets for Channel Monitor V2 config: +-- 1) ignored_error_categories: non-ops client/policy failures that should not +-- dominate health error_rate by default (still shown greyed in breakdown). +-- 2) health_thresholds cache floors: 85% watch / 60% critical (higher is better). +-- +-- Only apply when the row still looks like factory empty ignore list and/or +-- zero cache thresholds (operator customizations are left alone). + +UPDATE channel_monitor_v2_config +SET ignored_error_categories = ARRAY[ + 'authentication', + 'client_cancelled', + 'content_policy', + 'context_limit', + 'group_access', + 'model_unsupported', + 'not_found', + 'quota_or_balance' +]::text[] +WHERE id = 1 + AND COALESCE(cardinality(ignored_error_categories), 0) = 0; + +UPDATE channel_monitor_v2_config +SET health_thresholds = health_thresholds + || jsonb_build_object( + 'warning_cache_rate', 0.85, + 'critical_cache_rate', 0.60 + ) +WHERE id = 1 + AND COALESCE((health_thresholds->>'warning_cache_rate')::float8, 0) = 0 + AND COALESCE((health_thresholds->>'critical_cache_rate')::float8, 0) = 0; diff --git a/backend/migrations/204_channel_monitor_hide_throughput.sql b/backend/migrations/204_channel_monitor_hide_throughput.sql new file mode 100644 index 000000000..194eb7ce6 --- /dev/null +++ b/backend/migrations/204_channel_monitor_hide_throughput.sql @@ -0,0 +1,5 @@ +-- Soft switch: hide RPM/TPM throughput rates on user-facing Channel Monitor V2. +-- Default false (rates visible). Admins always see full metrics. +INSERT INTO settings (key, value) +VALUES ('channel_monitor_hide_throughput', 'false') +ON CONFLICT (key) DO NOTHING; diff --git a/backend/migrations/205_channel_monitor_v2_reset_factory_cache_thresholds.sql b/backend/migrations/205_channel_monitor_v2_reset_factory_cache_thresholds.sql new file mode 100644 index 000000000..f30e4e71f --- /dev/null +++ b/backend/migrations/205_channel_monitor_v2_reset_factory_cache_thresholds.sql @@ -0,0 +1,13 @@ +-- Keep factory cache scoring tolerant. Migration 203 briefly wrote 0.85/0.60; +-- reset only that exact factory pair, preserving operator-customized values. +UPDATE channel_monitor_v2_config +SET health_thresholds = health_thresholds + || jsonb_build_object( + 'warning_cache_rate', 0, + 'critical_cache_rate', 0 + ) +WHERE id = 1 + AND COALESCE((health_thresholds->>'warning_cache_rate')::float8, 0) = 0.85 + AND COALESCE((health_thresholds->>'critical_cache_rate')::float8, 0) = 0.60 + AND version = 1 + AND updated_by IS NULL; diff --git a/backend/migrations/206_channel_monitor_v2_privacy_defaults.sql b/backend/migrations/206_channel_monitor_v2_privacy_defaults.sql new file mode 100644 index 000000000..278e90b49 --- /dev/null +++ b/backend/migrations/206_channel_monitor_v2_privacy_defaults.sql @@ -0,0 +1,8 @@ +-- Correct factory defaults introduced by migrations 203 and 204 without +-- mutating those already-applied migration files (which are checksum-locked). +-- The setting was factory-created as false by migration 204. Existing +-- installations need the same privacy-preserving default as new installs. +UPDATE settings +SET value = 'true', updated_at = NOW() +WHERE key = 'channel_monitor_hide_throughput' + AND LOWER(TRIM(value)) IN ('false', '0', 'off', 'disabled'); diff --git a/docs/channel-monitor-v2-safe-defaults.md b/docs/channel-monitor-v2-safe-defaults.md new file mode 100644 index 000000000..75a9c9396 --- /dev/null +++ b/docs/channel-monitor-v2-safe-defaults.md @@ -0,0 +1,57 @@ +# Channel Monitor V2 Safe Defaults & Gentle Backfill + +**Date:** 2026-08-08 +**Status:** Approved for implementation +**Branch:** `fix/channel-monitor-v2-ops-ui-blockers` (onto channel-monitor-v2) + +## Problem + +1. **H2** — Migration 195 and code defaults set `channel_monitor_mode=v2`, so every upgrade without an existing key auto-opts into passive V2, stops V1 probes, and may leave users on an empty V2 view. +2. **M1** — First-enable bootstrap forces 5s ticks and ~24h `RecomputeRange` chunks until `now-30d`, hammering the primary DB. +3. Error dedup SQL matches `request_id` without a `created_at` bound, scanning `ops_error_logs` history via `candidate_ids`. + +## Decisions + +| Topic | Decision | +|-------|----------| +| Default mode | **v1** (keep active probes). V2 is explicit opt-in. | +| Existing `channel_monitor_mode=v2` rows | **Unchanged** (`ON CONFLICT DO NOTHING` / no force rewrite). | +| Switch to v2 | V1 runner `fire()` and `RunCheck` require `ActiveProbesAllowed()` → probes stop. | +| Backfill | Unified hard gates + adaptive soft gates (same rules, different observed load). | +| Optional profile knob | **Not in this change** (YAGNI). | + +## Mode defaults + +- Migration `195_channel_monitor_mode.sql`: insert `'v1'` when key missing. +- If 195 already applied with old checksum, add migration checksum compatibility (do not force rewrite applied DBs to v1). +- Code: `defaultChannelMonitorMode = v1`; empty/invalid normalize → v1. +- Frontend Settings form default and public feature flag fallback → v1 when missing/invalid. +- Nil settings on V1 `RunCheck` path remains **fail-closed** (no probes) for test safety — independent of product default. + +## Gentle backfill (low-resource phases) + +### Hard gates (all servers) + +- Tick interval = `refresh_interval` (60 or 300s). **No 5s bootstrap override.** +- Each tick: (1) recent overlap (~10m), then (2) **at most one** historical chunk while product bootstrap incomplete / retention walk ongoing. +- Single leader lock; single short transaction budget (~55s). +- Chunk ceiling by depth (product phases 90m → 1d → 7d → 30d → silent 90d). + +### Soft gates (adaptive) + +- Initial historical chunk: **1h** (first seed still **2h** for 90m UI). +- Min chunk: **15m**; max by depth: ≤1d → **2h**, ≤7d → **4h**, older → **6h**. +- Success + fast: grow slowly (×1.5) up to depth max. +- Failure/timeout: halve chunk; exponential backoff on wait (cap 10m). +- Progressive UI: show whatever is covered; bootstrap banner vs 30d product window. + +### Error dedup + +- Bound the `request_id IN candidate_ids` branch with + `created_at >= $1 - INTERVAL '90 minutes' AND created_at < $2`. + +## Out of scope + +- Force existing v2 → v1. +- Separate backfill workers / read replicas. +- Admin `backfill_profile` setting (may add later). diff --git a/frontend/src/api/__tests__/channelMonitorV2.spec.ts b/frontend/src/api/__tests__/channelMonitorV2.spec.ts new file mode 100644 index 000000000..403e0739d --- /dev/null +++ b/frontend/src/api/__tests__/channelMonitorV2.spec.ts @@ -0,0 +1,38 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { apiClient } from '../client' +import { getMatrix, repeatedArrayParamsSerializer } from '../channelMonitorV2' + +afterEach(() => vi.restoreAllMocks()) + +describe('channel monitor V2 query serialization', () => { + it('uses repeated keys without bracket suffixes for array filters', () => { + const query = repeatedArrayParamsSerializer({ + range: '90m', + platform: ['openai', 'grok'], + group_id: [1, 2], + model: undefined, + group_by: 'platform_group_model', + }) + + expect(query).toBe('range=90m&platform=openai&platform=grok&group_id=1&group_id=2&group_by=platform_group_model') + expect(query).not.toContain('%5B%5D') + }) + + it('sends the matrix grouping with the shared filters', async () => { + const get = vi.spyOn(apiClient, 'get').mockResolvedValue({ + data: { coverage: {}, group_by: 'platform_group', items: [] }, + }) + + await getMatrix({ range: '24h', platforms: ['openai'], groupIds: [7], models: [] }, 'platform_group', true) + + expect(get).toHaveBeenCalledWith('/admin/channel-monitor-v2/matrix', expect.objectContaining({ + params: { + range: '24h', + platform: ['openai'], + group_id: [7], + model: undefined, + group_by: 'platform_group', + }, + })) + }) +}) diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts index 4a839a919..08629df30 100644 --- a/frontend/src/api/admin/settings.ts +++ b/frontend/src/api/admin/settings.ts @@ -708,7 +708,9 @@ export interface SystemSettings { // Channel Monitor feature switch channel_monitor_enabled: boolean; + channel_monitor_mode?: 'v1' | 'v2'; channel_monitor_default_interval_seconds: number; + channel_monitor_hide_throughput?: boolean; // Available Channels feature switch available_channels_enabled: boolean; @@ -1003,7 +1005,9 @@ export interface UpdateSettingsRequest { // Channel Monitor feature switch channel_monitor_enabled?: boolean; + channel_monitor_mode?: 'v1' | 'v2'; channel_monitor_default_interval_seconds?: number; + channel_monitor_hide_throughput?: boolean; // Available Channels feature switch available_channels_enabled?: boolean; diff --git a/frontend/src/api/channelMonitorV2.ts b/frontend/src/api/channelMonitorV2.ts new file mode 100644 index 000000000..2320140bf --- /dev/null +++ b/frontend/src/api/channelMonitorV2.ts @@ -0,0 +1,271 @@ +import { apiClient } from './client' + +export type MonitorRange = '90m' | '24h' | '7d' | '30d' +export type HealthState = 'unknown' | 'healthy' | 'warning' | 'critical' +/** Fine-grained score band for multi-stop green→yellow→red gradients (score0..score10). */ +export type HealthScoreBand = + | 'unknown' + | 'score0' + | 'score1' + | 'score2' + | 'score3' + | 'score4' + | 'score5' + | 'score6' + | 'score7' + | 'score8' + | 'score9' + | 'score10' +export type MonitorMatrixGroupBy = 'platform' | 'platform_group' | 'platform_model' | 'platform_group_model' + +export interface MonitorFilter { + range: MonitorRange + platforms: string[] + groupIds: number[] + models: string[] +} + +export interface LatencyMetric { + sample_count: number + p50_ms: number | null + p90_ms?: number | null + p95_ms: number | null + avg_ms: number | null +} + +export interface MonitorMetric { + success_requests: number + error_requests: number + request_count: number + token_count: number + rpm: number + tpm: number + error_rate: number + cache_rate: number + cache_rate_numerator: number + cache_rate_denominator: number + ttft: LatencyMetric + duration: LatencyMetric + upstream_affected_requests?: number + upstream_attempt_count?: number +} + +export interface MonitorHealth { + overall: HealthState + error_rate: HealthState + ttft: HealthState + cache?: HealthState + /** 0–100 blended score when samples are sufficient. */ + score?: number | null + error_rate_score?: number | null + ttft_score?: number | null + cache_score?: number | null + minimum_sample: number + thresholds?: { + minimum_sample?: number + warning_error_rate: number + critical_error_rate: number + target_ttft_ms: number + warning_ttft_ms: number + critical_ttft_ms: number + warning_cache_rate?: number + critical_cache_rate?: number + error_weight: number + ttft_weight: number + cache_weight?: number + } +} + +/** First-upgrade historical fill for 90m/24h/7d/30d; omitted when complete. */ +export interface MonitorBootstrap { + active: boolean + progress_percent: number + covered_from?: string + target_start?: string +} + +export interface MonitorCoverage { + requested_start: string + /** Exclusive upper bound of the UI-selected range (filter end). */ + requested_end?: string + coverage_start: string + data_through: string + computed_at: string + aggregation_lag_seconds: number + coverage_complete: boolean + bucket_seconds: number + /** Present while initial aggregation has not covered the 30d product window. */ + bootstrap?: MonitorBootstrap | null +} + +export interface MonitorConfig { + version: number + enabled: boolean + refresh_interval_seconds: 60 | 300 + platforms: Array<{ platform: string; enabled: boolean; models: string[] }> + group_ids: number[] + health_thresholds: { + minimum_sample: number + warning_error_rate: number + critical_error_rate: number + target_ttft_ms: number + warning_ttft_ms: number + critical_ttft_ms: number + warning_cache_rate: number + critical_cache_rate: number + error_weight: number + ttft_weight: number + cache_weight: number + } + /** Categories excluded from error_rate / health; still listed in error breakdown. */ + ignored_error_categories?: string[] +} + +/** Ordered taxonomy mirrored from backend ChannelMonitorV2ErrorCategories. */ +export const MONITOR_ERROR_CATEGORIES = [ + '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', +] as const + +export type MonitorErrorCategory = (typeof MONITOR_ERROR_CATEGORIES)[number] + +export interface MonitorSnapshot { + config: MonitorConfig + coverage: MonitorCoverage + metrics: MonitorMetric + health: MonitorHealth + trend: Array<{ bucket_start: string; metrics: MonitorMetric; health: MonitorHealth }> +} + +export interface MonitorMatrixBucket { + bucket_start: string + metrics: MonitorMetric + health: MonitorHealth +} + +export interface MonitorMatrixRow { + platform: string + group_id?: number + group_name?: string + model?: string + metrics: MonitorMetric + health: MonitorHealth + buckets: MonitorMatrixBucket[] +} + +export interface MonitorMatrixResponse { + coverage: MonitorCoverage + group_by: MonitorMatrixGroupBy + items: MonitorMatrixRow[] +} + +export interface MonitorDimensions { + platforms: Array<{ value: string; label: string; request_count: number }> + groups: Array<{ id: number; name: string; platform?: string; request_count: number }> + models: Array<{ value: string; label: string; platform?: string; request_count: number }> +} + +export interface MonitorModelRow { platform: string; model: string; metrics: MonitorMetric; health: MonitorHealth } +export interface MonitorErrorRow { + category: string + count: number + rate: number + details?: Array<{ + platform?: string + model?: string + error_type?: string + status_code?: number + upstream_status_code?: number + message?: string + count: number + }> + /** true when category is in config.ignored_error_categories */ + ignored?: boolean +} +export interface MonitorUserRow { + user_id?: number + rank: number + email?: string + username?: string + display_label: string + is_self: boolean + can_drilldown: boolean + metrics: MonitorMetric +} + +function params(filter: MonitorFilter) { + return { + range: filter.range, + platform: filter.platforms.length ? filter.platforms : undefined, + group_id: filter.groupIds.length ? filter.groupIds : undefined, + model: filter.models.length ? filter.models : undefined, + } +} + +export function repeatedArrayParamsSerializer(values: Record): string { + const search = new URLSearchParams() + for (const [key, rawValue] of Object.entries(values)) { + if (rawValue == null || rawValue === '') continue + const entries = Array.isArray(rawValue) ? rawValue : [rawValue] + for (const value of entries) { + if (value != null && value !== '') search.append(key, String(value)) + } + } + return search.toString() +} + +const requestConfig = (filter: MonitorFilter, signal?: AbortSignal, extraParams: Record = {}) => ({ + params: { ...params(filter), ...extraParams }, + paramsSerializer: { serialize: repeatedArrayParamsSerializer }, + signal, +}) + +function base(admin: boolean) { return admin ? '/admin/channel-monitor-v2' : '/channel-monitor-v2' } + +export async function getDimensions(filter: MonitorFilter, admin = false, signal?: AbortSignal) { + const { data } = await apiClient.get(`${base(admin)}/dimensions`, requestConfig(filter, signal)) + return data +} +export async function getSnapshot(filter: MonitorFilter, admin = false, signal?: AbortSignal) { + const { data } = await apiClient.get(`${base(admin)}/snapshot`, requestConfig(filter, signal)) + return data +} +export async function getMatrix(filter: MonitorFilter, groupBy: MonitorMatrixGroupBy, admin = false, signal?: AbortSignal) { + const { data } = await apiClient.get(`${base(admin)}/matrix`, requestConfig(filter, signal, { group_by: groupBy })) + return data +} +export async function getModels(filter: MonitorFilter, admin = false, signal?: AbortSignal) { + const { data } = await apiClient.get<{ coverage: MonitorCoverage; items: MonitorModelRow[] }>(`${base(admin)}/models`, requestConfig(filter, signal)) + return data +} +export async function getErrors(filter: MonitorFilter, admin = false, signal?: AbortSignal) { + const { data } = await apiClient.get<{ coverage: MonitorCoverage; items: MonitorErrorRow[] }>(`${base(admin)}/errors`, requestConfig(filter, signal)) + return data +} +export async function getUsers(filter: MonitorFilter, admin = false, signal?: AbortSignal) { + const { data } = await apiClient.get<{ coverage: MonitorCoverage; items: MonitorUserRow[] }>(`${base(admin)}/users`, requestConfig(filter, signal)) + return data +} +export async function getConfig() { + const { data } = await apiClient.get('/admin/channel-monitor-v2/config') + return data +} +export async function updateConfig(config: MonitorConfig) { + const { data } = await apiClient.put('/admin/channel-monitor-v2/config', config) + return data +} diff --git a/frontend/src/components/icons/Icon.vue b/frontend/src/components/icons/Icon.vue index 6d0ba8a3f..7859adee7 100644 --- a/frontend/src/components/icons/Icon.vue +++ b/frontend/src/components/icons/Icon.vue @@ -67,6 +67,8 @@ const icons = { xCircle: 'M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z', exclamationCircle: 'M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z', exclamationTriangle: 'M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z', + // Awards + trophy: 'M16.5 18.75h-9m9 0a3 3 0 013 3h-15a3 3 0 013-3m9 0v-4.5A3.375 3.375 0 0012.75 11.25h-1.5A3.375 3.375 0 008.25 14.25v4.5m8.25-12V6.75a.75.75 0 00-.75-.75h-1.5a.75.75 0 00-.75.75v1.5m0 0V9a.75.75 0 01-.75.75H9.75A.75.75 0 019 9V8.25m0 0V6.75a.75.75 0 00-.75-.75h-1.5a.75.75 0 00-.75.75v1.5m12 0h.008v.008h-.008V8.25zm-12 0h.008v.008H5.25V8.25z', infoCircle: 'M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z', questionCircle: 'M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z', diff --git a/frontend/src/features/channel-monitor-v2/FilterMultiSelect.vue b/frontend/src/features/channel-monitor-v2/FilterMultiSelect.vue new file mode 100644 index 000000000..2eea94796 --- /dev/null +++ b/frontend/src/features/channel-monitor-v2/FilterMultiSelect.vue @@ -0,0 +1,271 @@ + + + + + diff --git a/frontend/src/features/channel-monitor-v2/MetricCell.vue b/frontend/src/features/channel-monitor-v2/MetricCell.vue new file mode 100644 index 000000000..aa3eee65e --- /dev/null +++ b/frontend/src/features/channel-monitor-v2/MetricCell.vue @@ -0,0 +1,73 @@ + + + diff --git a/frontend/src/features/channel-monitor-v2/MonitorRankBadge.vue b/frontend/src/features/channel-monitor-v2/MonitorRankBadge.vue new file mode 100644 index 000000000..14e2932a8 --- /dev/null +++ b/frontend/src/features/channel-monitor-v2/MonitorRankBadge.vue @@ -0,0 +1,122 @@ + + + diff --git a/frontend/src/features/channel-monitor-v2/MonitorSettingsPanel.vue b/frontend/src/features/channel-monitor-v2/MonitorSettingsPanel.vue new file mode 100644 index 000000000..3fdf1e584 --- /dev/null +++ b/frontend/src/features/channel-monitor-v2/MonitorSettingsPanel.vue @@ -0,0 +1,441 @@ + + + diff --git a/frontend/src/features/channel-monitor-v2/MonitorTrendChart.vue b/frontend/src/features/channel-monitor-v2/MonitorTrendChart.vue new file mode 100644 index 000000000..012eeb463 --- /dev/null +++ b/frontend/src/features/channel-monitor-v2/MonitorTrendChart.vue @@ -0,0 +1,273 @@ + + + diff --git a/frontend/src/features/channel-monitor-v2/RelayPulseMatrix.vue b/frontend/src/features/channel-monitor-v2/RelayPulseMatrix.vue new file mode 100644 index 000000000..70e307c46 --- /dev/null +++ b/frontend/src/features/channel-monitor-v2/RelayPulseMatrix.vue @@ -0,0 +1,664 @@ + + + + + diff --git a/frontend/src/features/channel-monitor-v2/__tests__/MetricCell.spec.ts b/frontend/src/features/channel-monitor-v2/__tests__/MetricCell.spec.ts new file mode 100644 index 000000000..420d16627 --- /dev/null +++ b/frontend/src/features/channel-monitor-v2/__tests__/MetricCell.spec.ts @@ -0,0 +1,50 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' +import MetricCell from '../MetricCell.vue' + +describe('MetricCell', () => { + it('renders label/value/detail through shipped template with design-system classes', () => { + const wrapper = mount(MetricCell, { + props: { + label: '请求', + value: '1,234', + detail: '12.5 RPM', + state: 'healthy', + }, + }) + + expect(wrapper.classes().join(' ') + wrapper.html()).toContain('stat-card') + expect(wrapper.text()).toContain('请求') + expect(wrapper.text()).toContain('1,234') + expect(wrapper.text()).toContain('12.5 RPM') + expect(wrapper.find('strong').classes().join(' ')).toMatch(/emerald/) + }) + + it('maps warning and critical health states to distinct colors', () => { + const warning = mount(MetricCell, { + props: { label: '错误', value: '10%', detail: '1 次', state: 'warning' }, + }) + expect(warning.find('strong').classes().join(' ')).toMatch(/amber/) + + const critical = mount(MetricCell, { + props: { label: '错误', value: '50%', detail: '5 次', state: 'critical' }, + }) + expect(critical.find('strong').classes().join(' ')).toMatch(/red/) + }) + + it('renders multi-part detail as non-truncated chips (AVG · P90 fully visible)', () => { + const wrapper = mount(MetricCell, { + props: { + label: '首 Token P50', + value: '400ms', + detail: 'AVG 475ms · P90 800ms', + state: 'healthy', + }, + }) + expect(wrapper.find('small.truncate').exists()).toBe(false) + expect(wrapper.find('.whitespace-nowrap').exists()).toBe(true) + expect(wrapper.text()).toContain('AVG 475ms') + expect(wrapper.text()).toContain('P90 800ms') + expect(wrapper.text()).not.toContain('…') + }) +}) diff --git a/frontend/src/features/channel-monitor-v2/__tests__/RelayPulseMatrix.spec.ts b/frontend/src/features/channel-monitor-v2/__tests__/RelayPulseMatrix.spec.ts new file mode 100644 index 000000000..e096a5d74 --- /dev/null +++ b/frontend/src/features/channel-monitor-v2/__tests__/RelayPulseMatrix.spec.ts @@ -0,0 +1,176 @@ +const i18nT = (key: string, params?: Record) => { + const map: Record = { + 'channelMonitorV2.matrix.title': '色块矩阵', + 'channelMonitorV2.matrix.description': '趋势色块视图', + 'channelMonitorV2.bucket.minutes': '{count}分钟', + 'channelMonitorV2.matrix.wheelZoomX': '滚轮缩放横向', + 'channelMonitorV2.matrix.resetZoom': '重置缩放', + 'channelMonitorV2.matrix.dimension': '维度', + 'channelMonitorV2.metrics.successRate': '成功率', + 'channelMonitorV2.metrics.ttft': '首 Token', + 'channelMonitorV2.metrics.tps': '每秒 Token', + 'channelMonitorV2.metrics.cacheRate': '缓存率', + 'channelMonitorV2.matrix.scoreLine': '评分 {score}', + 'channelMonitorV2.metrics.successRateValue': '成功率 {value}', + 'channelMonitorV2.metrics.errorRateValue': '错误率 {value}', + 'channelMonitorV2.metrics.rpmValue': 'RPM {value}', + 'channelMonitorV2.metrics.tpmValue': 'TPM {value}', + 'channelMonitorV2.metrics.tpsValue': '每秒 Token {value}', + 'channelMonitorV2.metrics.ttftValue': '首 Token {value}', + 'channelMonitorV2.metrics.durationValue': '时长 {value}', + 'channelMonitorV2.metrics.cacheRateValue': '缓存率 {value}', + 'channelMonitorV2.matrix.noTrafficAt': '{time} 无流量', + 'channelMonitorV2.matrix.noTraffic': '无流量', + 'channelMonitorV2.matrix.legendAria': '图例', + 'channelMonitorV2.matrix.bad': '差', + 'channelMonitorV2.matrix.good': '好', + 'channelMonitorV2.matrix.healthyLegend': '健康', + 'channelMonitorV2.matrix.warningLegend': '警告', + 'channelMonitorV2.matrix.criticalLegend': '异常', + 'channelMonitorV2.matrix.unknownLegend': '未知', + } + const template = map[key] || key + return template.replace(/\{(\w+)\}/g, (_, name) => String(params?.[name] ?? '')) +} + +vi.mock('vue-i18n', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useI18n: () => ({ + t: i18nT, + te: (key: string) => key.startsWith('channelMonitorV2.'), + locale: { value: 'zh' }, + }), + } +}) + +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' +import RelayPulseMatrix from '../RelayPulseMatrix.vue' +import type { MonitorHealth, MonitorMetric } from '@/api/channelMonitorV2' + +const health: MonitorHealth = { + overall: 'warning', + error_rate: 'warning', + ttft: 'healthy', + cache: 'warning', + score: 52, + error_rate_score: 40, + ttft_score: 100, + cache_score: 50, + minimum_sample: 20, +} + +function metrics(requestCount: number): MonitorMetric { + return { + success_requests: requestCount ? requestCount - 1 : 0, + error_requests: requestCount ? 1 : 0, + request_count: requestCount, + token_count: 100, + rpm: 1.5, + tpm: 10.2, + error_rate: requestCount ? 1 / requestCount : 0, + cache_rate: 0.5, + cache_rate_numerator: 50, + cache_rate_denominator: 100, + ttft: { sample_count: requestCount, p50_ms: 100, p95_ms: 300, avg_ms: 150 }, + duration: { sample_count: requestCount, p50_ms: 500, p95_ms: 900, avg_ms: 600 }, + upstream_affected_requests: 2, + upstream_attempt_count: 3, + } +} + +describe('RelayPulseMatrix', () => { + it('shows privacy-safe hover tooltips and multi-band colors without click modal', async () => { + const wrapper = mount(RelayPulseMatrix, { + props: { + rows: [{ + platform: 'openai', + group_id: 7, + group_name: '默认组', + model: 'gpt-5', + metrics: metrics(10), + health, + buckets: [ + { bucket_start: '2026-08-01T00:00:00Z', metrics: metrics(10), health }, + { bucket_start: '2026-08-01T00:01:00Z', metrics: metrics(0), health }, + ], + }], + coverage: { + requested_start: '2026-08-01T00:00:00Z', + requested_end: '2026-08-01T00:03:00Z', + coverage_start: '2026-08-01T00:00:00Z', + data_through: '2026-08-01T00:03:00Z', + computed_at: '2026-08-01T00:03:00Z', + aggregation_lag_seconds: 0, + coverage_complete: true, + bucket_seconds: 60, + }, + healthMode: 'overall', + }, + }) + + const cells = wrapper.findAll('.pulse-cell') + expect(cells).toHaveLength(3) + // Hover tooltip content (privacy-safe: no absolute request/error counts) + const tip = cells[0].text() + expect(tip).toContain('成功率') + expect(tip).toContain('90.0%') + expect(tip).toContain('首 Token') + expect(tip).toContain('每秒 Token') + expect(tip).toContain('缓存率') + expect(tip).toContain('RPM') + expect(tip).not.toContain('请求数') + expect(tip).not.toContain('用户错误') + expect(tip).not.toContain('上游受影响') + // Summary columns: success · ttft · tokens/s · cache + const header = wrapper.find('.matrix-header').text() + expect(header).toContain('成功率') + expect(header).toContain('首 Token') + expect(header).toContain('每秒 Token') + expect(header).toContain('缓存率') + // Multi-band class from score 52 → score5 + expect(cells[0].classes().some((c) => c.startsWith('health-score'))).toBe(true) + // Redacted user payloads may have request_count=0 but still include score. + expect(cells[1].classes().some((c) => c.startsWith('health-score'))).toBe(true) + expect(cells[2].classes()).toContain('health-unknown') + + // No click-to-open modal + await cells[0].trigger('click') + expect(wrapper.find('[role="dialog"]').exists()).toBe(false) + }) +}) + +describe('RelayPulseMatrix axis range', () => { + it('uses selected requested range for the X axis even with partial coverage', () => { + const wrapper = mount(RelayPulseMatrix, { + props: { + rows: [{ + platform: 'openai', + group_id: 7, + group_name: '默认组', + model: 'gpt-5', + metrics: metrics(10), + health, + buckets: [ + { bucket_start: '2026-08-01T00:02:00Z', metrics: metrics(10), health }, + ], + }], + coverage: { + requested_start: '2026-08-01T00:00:00Z', + requested_end: '2026-08-01T00:05:00Z', + coverage_start: '2026-08-01T00:02:00Z', + data_through: '2026-08-01T00:03:00Z', + computed_at: '2026-08-01T00:03:00Z', + aggregation_lag_seconds: 0, + coverage_complete: false, + bucket_seconds: 60, + }, + healthMode: 'overall', + }, + }) + // 5 minutes @ 60s buckets → 5 cells spanning the selected range + expect(wrapper.findAll('.pulse-cell')).toHaveLength(5) + }) +}) diff --git a/frontend/src/features/channel-monitor-v2/__tests__/designSystem.structure.spec.ts b/frontend/src/features/channel-monitor-v2/__tests__/designSystem.structure.spec.ts new file mode 100644 index 000000000..9aa2a2352 --- /dev/null +++ b/frontend/src/features/channel-monitor-v2/__tests__/designSystem.structure.spec.ts @@ -0,0 +1,104 @@ +/** + * Structure contracts: channel-monitor-v2 + studio shells must use project + * design-system utility classes rather than isolated flat RGB skins. + */ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { describe, expect, it } from 'vitest' + +const root = resolve(__dirname, '../../..') + +function read(rel: string) { + return readFileSync(resolve(root, rel), 'utf8') +} + +describe('channel-monitor-v2 design system structure', () => { + it('user ChannelStatus V2 shell uses page-header, card, btn, tabs utilities', () => { + // Route wrapper may switch V1/V2; design chrome lives on the V2 implementation. + const src = read('views/user/ChannelStatusV2View.vue') + expect(src).toContain('page-header') + expect(src).toContain('page-title') + expect(src).toContain('class="card') + expect(src).toContain('btn btn-secondary') + expect(src).toContain('class="tab') + expect(src).toContain('tab-active') + expect(src).toContain('badge badge-warning') + // Compact single-row toolbar + expect(src).toContain('monitor-toolbar') + expect(src).toContain('clearFilters') + expect(src).toContain('healthModeOptions') + expect(src).toContain("'cache'") + // Ops elevation: rounded-3xl + ring surfaces + expect(src).toContain('rounded-3xl') + expect(src).toContain('ring-1 ring-gray-900/5') + // Overview-first KPI strip before primary viz + expect(src.indexOf('summaryAria')).toBeLessThan(src.indexOf('MonitorTrendChart')) + // No page-level fixed min-width that forces viewport horizontal scroll + expect(src).not.toMatch(/min-width:\s*980px/) + expect(src).not.toMatch(/min-w-\[980px\]/) + // Dense tables scroll internally + expect(src).toMatch(/max-h-\[min\(52vh/) + expect(src).toContain('overflow-auto') + // Trend view toggle (pulse matrix / line chart) + default platform/group dimension + expect(src).toContain("trendView") + expect(src).toContain("'platform_group'") + expect(src).toContain('MonitorTrendChart') + }) + + it('RelayPulseMatrix uses card chrome, matrix scroll, and hover tooltips (no click modal)', () => { + const src = read('features/channel-monitor-v2/RelayPulseMatrix.vue') + expect(src).toContain('class="card') + expect(src).toContain('card-header') + expect(src).toContain('card-body') + expect(src).toContain('matrix-scroll') + expect(src).toMatch(/max-h-\[min\(42vh/) + expect(src).toContain('overflow-auto') + expect(src).toContain('pulse-tooltip') + expect(src).toContain('rounded-3xl') + expect(src).toContain('ring-1 ring-gray-900/5') + expect(src).not.toContain('modal-overlay') + expect(src).not.toContain('modal-content') + }) + + it('MetricCell uses stat-card utility', () => { + const src = read('features/channel-monitor-v2/MetricCell.vue') + expect(src).toContain('stat-card') + expect(src).toContain('stat-label') + expect(src).toContain('stat-value') + expect(src).toContain('rounded-3xl') + }) + + it('MonitorTrendChart uses Ops chart shell tokens', () => { + const src = read('features/channel-monitor-v2/MonitorTrendChart.vue') + expect(src).toContain('class="card') + expect(src).toContain('rounded-3xl') + expect(src).toContain('ring-1 ring-gray-900/5') + expect(src).toContain('EmptyState') + expect(src).toContain('min-h-[360px]') + }) + + it('FilterMultiSelect uses rounded-xl input chrome and dropdown utility', () => { + const src = read('features/channel-monitor-v2/FilterMultiSelect.vue') + expect(src).toContain('rounded-xl') + expect(src).toContain('dropdown') + expect(src).toContain('dropdown-item') + }) + + it('MonitorSettingsPanel uses page-header, card, btn-primary, tabs', () => { + const src = read('features/channel-monitor-v2/MonitorSettingsPanel.vue') + expect(src).toContain('page-header') + expect(src).toContain('btn btn-primary') + expect(src).toContain('class="card') + expect(src).toContain('tab-active') + expect(src).toMatch(/max-h-\[min\(40vh/) + }) + + it('admin ChannelMonitorView V2 tab chrome uses project tabs', () => { + const src = read('views/admin/ChannelMonitorView.vue') + expect(src).toContain('page-header') + expect(src).toContain('page-title') + expect(src).toContain('class="tabs') + expect(src).toContain('tab-active') + expect(src).toContain('MonitorSettingsPanel') + }) +}) diff --git a/frontend/src/features/channel-monitor-v2/__tests__/monitorFormat.spec.ts b/frontend/src/features/channel-monitor-v2/__tests__/monitorFormat.spec.ts new file mode 100644 index 000000000..affbcdcde --- /dev/null +++ b/frontend/src/features/channel-monitor-v2/__tests__/monitorFormat.spec.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from 'vitest' +import { + formatLatencyKpiSecondary, + formatLatencyPrivacy, + formatMonitorMs, + formatMonitorNumber, + formatMonitorPercent, + formatMonitorRate, + formatMonitorSuccessRate, + formatMonitorSuccessRateFromError, + formatMonitorThroughput, + formatMonitorTokensPerSecond, + healthScoreClass, + healthStateClass, + scoreToBand, + tokensPerSecondFromTpm, +} from '../monitorFormat' +import type { MonitorHealth } from '@/api/channelMonitorV2' + +describe('monitorFormat accuracy', () => { + it('converts backend TPM (per minute) to tokens/sec for display', () => { + expect(tokensPerSecondFromTpm(60)).toBe(1) + expect(tokensPerSecondFromTpm(6000)).toBe(100) + expect(formatMonitorTokensPerSecond(60)).toBe('1') + expect(formatMonitorTokensPerSecond(null)).toBe('0') + }) + + it('formats request/token counts with compact notation above threshold', () => { + expect(formatMonitorNumber(10)).toBe('10') + expect(formatMonitorNumber(9999)).toBe('9,999') + // compact at >= 10000 + expect(formatMonitorNumber(10000)).toMatch(/1/) + expect(formatMonitorNumber(0)).toBe('0') + }) + + it('formats rates and percents from raw API fractions without hardcoding pretty values', () => { + expect(formatMonitorRate(12.56)).toBe('12.6') + expect(formatMonitorPercent(0.1)).toBe('10.0%') + expect(formatMonitorPercent(0.005)).toBe('0.50%') + // value < 0.01 uses 2 decimal places (includes exact 0) + expect(formatMonitorPercent(0)).toBe('0.00%') + expect(formatMonitorPercent(0.5)).toBe('50.0%') + }) + + it('formats RPM/TPM throughput with K/M compact notation', () => { + expect(formatMonitorThroughput(12.56)).toBe('12.6') + expect(formatMonitorThroughput(999)).toMatch(/999/) + expect(formatMonitorThroughput(1000)).toBe('1.0K') + expect(formatMonitorThroughput(1500)).toBe('1.5K') + expect(formatMonitorThroughput(1_000_000)).toBe('1.0M') + expect(formatMonitorThroughput(1_500_000)).toBe('1.5M') + expect(formatMonitorThroughput(0)).toBe('0') + }) + + it('formats latency ms/s boundaries from API fields', () => { + expect(formatMonitorMs(null)).toBe('-') + expect(formatMonitorMs(300)).toBe('300ms') + expect(formatMonitorMs(1500)).toBe('1.5s') + expect(formatMonitorMs(999)).toBe('999ms') + }) + + it('formats KPI secondary latency as AVG · P90 (P50 is primary)', () => { + expect(formatLatencyKpiSecondary(150, 400)).toBe('AVG 150ms · P90 400ms') + expect(formatLatencyKpiSecondary(1500, null, 2000)).toBe('AVG 1.5s · P95 2.0s') + expect(formatLatencyKpiSecondary(null, null)).toBe('-') + }) + + it('computes success rate from success_requests / request_count', () => { + expect(formatMonitorSuccessRate(9, 10)).toBe('90.0%') + expect(formatMonitorSuccessRate(0, 0)).toBe('-') + expect(formatMonitorSuccessRate(1, 3)).toBe('33.3%') + }) + + it('derives success rate from error_rate without absolute counts', () => { + expect(formatMonitorSuccessRateFromError(0.1)).toBe('90.0%') + expect(formatMonitorSuccessRateFromError(0)).toBe('100.0%') + }) + + it('maps continuous scores to multi-stop bands', () => { + expect(scoreToBand(100)).toBe('score10') + expect(scoreToBand(95)).toBe('score10') + expect(scoreToBand(80)).toBe('score8') + expect(scoreToBand(50)).toBe('score5') + expect(scoreToBand(0)).toBe('score0') + expect(scoreToBand(null)).toBe('unknown') + }) + + it('builds score-based health classes for matrix cells', () => { + const health: MonitorHealth = { + overall: 'warning', + error_rate: 'warning', + ttft: 'healthy', + cache: 'warning', + score: 52, + error_rate_score: 40, + ttft_score: 100, + cache_score: 50, + minimum_sample: 20, + } + expect(healthScoreClass(health, 'overall', 10)).toBe('health-score5') + expect(healthScoreClass(health, 'success', 10)).toBe('health-score4') + expect(healthScoreClass(health, 'ttft', 10)).toBe('health-score10') + expect(healthScoreClass(health, 'cache', 10)).toBe('health-score5') + // Redacted user payloads have request_count=0 but keep score fields. + expect(healthScoreClass(health, 'overall', 0)).toBe('health-score5') + expect(healthScoreClass({ ...health, score: null }, 'overall', 0)).toBe('health-unknown') + }) + + it('maps health states for status dots', () => { + expect(healthStateClass('healthy')).toBe('health-healthy') + expect(healthStateClass(undefined)).toBe('health-unknown') + }) + + it('formats privacy-safe latency lines with avg/p50/p90', () => { + expect(formatLatencyPrivacy(100, 250, 120, 300)).toBe('AVG 120ms · P50 100ms · P90 250ms') + expect(formatLatencyPrivacy(100, null, null, 300)).toBe('P50 100ms · P95 300ms') + expect(formatLatencyPrivacy(null, null)).toBe('-') + }) +}) diff --git a/frontend/src/features/channel-monitor-v2/__tests__/monitorZoom.spec.ts b/frontend/src/features/channel-monitor-v2/__tests__/monitorZoom.spec.ts new file mode 100644 index 000000000..3630160ea --- /dev/null +++ b/frontend/src/features/channel-monitor-v2/__tests__/monitorZoom.spec.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest' +import { + DEFAULT_ZOOM, + MIN_ZOOM_SPAN, + applyWheelZoom, + clampZoom, + isZoomed, + resetZoom, + sliceByZoom, +} from '../monitorZoom' + +describe('monitorZoom', () => { + it('resets to full span', () => { + expect(resetZoom()).toEqual(DEFAULT_ZOOM) + expect(isZoomed(DEFAULT_ZOOM)).toBe(false) + }) + + it('zooms in around cursor without always pinning to the end', () => { + const mid = applyWheelZoom(DEFAULT_ZOOM, { deltaY: -100, deltaX: 0, shiftKey: false }, 0.5) + expect(mid.span).toBeLessThan(1) + expect(mid.span).toBeGreaterThanOrEqual(MIN_ZOOM_SPAN) + // Center zoom keeps window roughly centered + expect(mid.offset).toBeGreaterThan(0) + expect(mid.offset + mid.span).toBeLessThanOrEqual(1.001) + + const left = applyWheelZoom(DEFAULT_ZOOM, { deltaY: -100, deltaX: 0, shiftKey: false }, 0) + expect(left.offset).toBeLessThanOrEqual(0.001) + + const right = applyWheelZoom(DEFAULT_ZOOM, { deltaY: -100, deltaX: 0, shiftKey: false }, 1) + expect(right.offset + right.span).toBeGreaterThan(0.99) + }) + + it('pans with shift+wheel', () => { + const zoomed = clampZoom({ span: 0.4, offset: 0.1 }) + const panned = applyWheelZoom(zoomed, { deltaY: 200, deltaX: 0, shiftKey: true }, 0.5) + expect(panned.span).toBeCloseTo(zoomed.span, 5) + expect(panned.offset).toBeGreaterThan(zoomed.offset) + }) + + it('clamps span to MIN_ZOOM_SPAN and keeps window in range', () => { + const tight = clampZoom({ span: 0.01, offset: 0.9 }) + expect(tight.span).toBe(MIN_ZOOM_SPAN) + expect(tight.offset + tight.span).toBeLessThanOrEqual(1.001) + }) + + it('slices series by window, not always last N points', () => { + const items = Array.from({ length: 100 }, (_, i) => i) + const startWindow = sliceByZoom(items, { span: 0.2, offset: 0 }) + expect(startWindow[0]).toBe(0) + expect(startWindow.length).toBe(20) + + const midWindow = sliceByZoom(items, { span: 0.2, offset: 0.4 }) + expect(midWindow[0]).toBe(40) + expect(midWindow[midWindow.length - 1]).toBe(59) + + const full = sliceByZoom(items, DEFAULT_ZOOM) + expect(full).toHaveLength(100) + }) +}) diff --git a/frontend/src/features/channel-monitor-v2/monitorFormat.ts b/frontend/src/features/channel-monitor-v2/monitorFormat.ts new file mode 100644 index 000000000..2589e0670 --- /dev/null +++ b/frontend/src/features/channel-monitor-v2/monitorFormat.ts @@ -0,0 +1,173 @@ +/** + * Shared display formatters for channel-monitor-v2. + * Kept pure so unit tests can lock metric presentation accuracy. + * + * Privacy: prefer rates (error_rate, RPM, TPM, success %) over absolute + * request/error/token counts in user-facing surfaces. + */ + +import type { HealthScoreBand, MonitorHealth } from '@/api/channelMonitorV2' +import { formatCompactNumber } from '@/utils/format' + +export function monitorIntlLocale(): string { + if (typeof document !== 'undefined') { + const htmlLang = document.documentElement.getAttribute('lang')?.trim() + if (htmlLang) return htmlLang + } + if (typeof navigator !== 'undefined' && navigator.language) return navigator.language + return 'zh-CN' +} + +export function formatMonitorNumber(value: number, compactAt = 10000, locale = monitorIntlLocale()): string { + return Intl.NumberFormat(locale, { + notation: value >= compactAt ? 'compact' : 'standard', + maximumFractionDigits: 1, + }).format(value || 0) +} + +export function formatMonitorRate(value: number, locale = monitorIntlLocale()): string { + return Intl.NumberFormat(locale, { maximumFractionDigits: 1 }).format(value || 0) +} + +/** + * RPM/TPM throughput display: always K/M compact (1.0K, 1.5M) for ops-readable density. + * Prefer this over formatMonitorRate for rate KPIs and table cells. + */ +export function formatMonitorThroughput(value: number | null | undefined): string { + if (value == null || Number.isNaN(Number(value))) return '0' + const n = Number(value) + if (Math.abs(n) < 1000) { + return Intl.NumberFormat(monitorIntlLocale(), { maximumFractionDigits: 1 }).format(n) + } + return formatCompactNumber(n) +} + +/** + * Backend stores tokens-per-minute as `tpm`. Convert to tokens-per-second for display. + */ +export function tokensPerSecondFromTpm(tpm: number | null | undefined): number { + if (tpm == null || Number.isNaN(Number(tpm))) return 0 + return Number(tpm) / 60 +} + +/** Tokens/sec from backend TPM (per-minute), compact ops formatting. */ +export function formatMonitorTokensPerSecond(tpm: number | null | undefined): string { + return formatMonitorThroughput(tokensPerSecondFromTpm(tpm)) +} + + +export function formatMonitorPercent(value: number, locale = monitorIntlLocale()): string { + return `${new Intl.NumberFormat(locale, { + minimumFractionDigits: value < 0.01 ? 2 : 1, + maximumFractionDigits: value < 0.01 ? 2 : 1, + }).format((value || 0) * 100)}%` +} + +export function formatMonitorMs(value: number | null | undefined): string { + if (value == null) return '-' + return value >= 1000 ? `${(value / 1000).toFixed(1)}s` : `${Math.round(value)}ms` +} + +export function formatMonitorSuccessRate(successRequests: number, requestCount: number): string { + if (!requestCount) return '-' + return formatMonitorPercent(successRequests / requestCount) +} + +export function formatMonitorSuccessRateFromError(errorRate: number): string { + return formatMonitorPercent(1 - (errorRate || 0)) +} + +/** + * Map continuous 0–100 score to 11 fine bands for multi-stop green→yellow→red. + * score10 = best (green), score0 = worst (red). + */ +export function scoreToBand(score: number | null | undefined): HealthScoreBand { + if (score == null || Number.isNaN(score)) return 'unknown' + const clamped = Math.max(0, Math.min(100, score)) + // 0–100 → score0..score10 (11 stops for green→yellow→red gradient) + const band = Math.round(clamped / 10) + return `score${Math.max(0, Math.min(10, band))}` as HealthScoreBand +} + +export type HealthDisplayMode = 'overall' | 'success' | 'ttft' | 'cache' + +/** Resolve the score used for a health mode. */ +export function healthModeScore( + health: MonitorHealth, + mode: HealthDisplayMode, +): number | null { + if (mode === 'success') { + return health.error_rate_score ?? null + } + if (mode === 'ttft') { + return health.ttft_score ?? null + } + if (mode === 'cache') { + return health.cache_score ?? null + } + return health.score ?? null +} + +export function healthScoreClass( + health: MonitorHealth, + mode: HealthDisplayMode, + requestCount: number, +): string { + const score = healthModeScore(health, mode) + if (score == null) { + if (requestCount <= 0) return 'health-unknown' + // Fall back to coarse state when score is absent (older payloads). + const coarse = + mode === 'success' + ? health.error_rate + : mode === 'ttft' + ? health.ttft + : mode === 'cache' + ? health.cache + : health.overall + return healthStateClass(coarse) + } + return `health-${scoreToBand(score)}` +} + +export function healthStateClass(state: string | undefined): string { + return `health-${state || 'unknown'}` +} + +/** Privacy-safe latency summary: avg + p50 + p90 (no absolute sample counts). */ +export function formatLatencyPrivacy( + p50: number | null | undefined, + p90: number | null | undefined, + avg?: number | null | undefined, + p95?: number | null | undefined, +): string { + const parts: string[] = [] + if (avg != null) parts.push(`AVG ${formatMonitorMs(avg)}`) + if (p50 != null) parts.push(`P50 ${formatMonitorMs(p50)}`) + if (p90 != null) parts.push(`P90 ${formatMonitorMs(p90)}`) + // p95 only as fallback when p90 missing (older payloads) + if (p90 == null && p95 != null) parts.push(`P95 ${formatMonitorMs(p95)}`) + return parts.length ? parts.join(' · ') : '-' +} + +/** + * KPI secondary line for latency: AVG + P90 only (P50 is the primary value). + * Falls back to P95 when P90 is absent. Delimiter is " · " so MetricCell can + * split into non-truncated chips. + */ +export function formatLatencyKpiSecondary( + avg?: number | null | undefined, + p90?: number | null | undefined, + p95?: number | null | undefined, +): string { + const parts: string[] = [] + if (avg != null && Number.isFinite(avg)) parts.push(`AVG ${formatMonitorMs(avg)}`) + if (p90 != null && Number.isFinite(p90)) parts.push(`P90 ${formatMonitorMs(p90)}`) + else if (p95 != null && Number.isFinite(p95)) parts.push(`P95 ${formatMonitorMs(p95)}`) + return parts.length ? parts.join(' · ') : '-' +} + +/** Fallback when i18n key is missing; prefer `channelMonitorV2.errorCategories.*`. */ +export function monitorErrorCategoryLabel(category: string): string { + return category +} diff --git a/frontend/src/features/channel-monitor-v2/monitorZoom.ts b/frontend/src/features/channel-monitor-v2/monitorZoom.ts new file mode 100644 index 000000000..8bd6ff949 --- /dev/null +++ b/frontend/src/features/channel-monitor-v2/monitorZoom.ts @@ -0,0 +1,103 @@ +/** + * Pure X-axis zoom/pan for channel-monitor pulse matrix and line chart. + * No chartjs-plugin-zoom dependency — state is span + offset fractions. + */ + +export interface ZoomState { + /** Visible fraction of the full series (1 = full range). */ + span: number + /** Start of the visible window as a fraction of the full domain [0, 1 - span]. */ + offset: number +} + +export const DEFAULT_ZOOM: ZoomState = { span: 1, offset: 0 } +export const MIN_ZOOM_SPAN = 0.12 + +const ZOOM_STEP = 0.12 + +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)) +} + +export function resetZoom(): ZoomState { + return { ...DEFAULT_ZOOM } +} + +export function isZoomed(state: ZoomState): boolean { + return state.span < 0.999 || state.offset > 0.001 +} + +/** Normalize offset so the visible window stays within [0, 1]. */ +export function clampZoom(state: ZoomState): ZoomState { + const span = clamp(state.span, MIN_ZOOM_SPAN, 1) + const maxOffset = Math.max(0, 1 - span) + return { + span, + offset: clamp(state.offset, 0, maxOffset), + } +} + +/** + * Wheel zoom around cursor xRatio (0–1 within the chart/matrix track). + * Shift+wheel or horizontal delta pans offset instead. + */ +export function applyWheelZoom( + state: ZoomState, + event: Pick, + xRatio: number, +): ZoomState { + const ratio = clamp(Number.isFinite(xRatio) ? xRatio : 0.5, 0, 1) + const isPan = event.shiftKey || Math.abs(event.deltaX) > Math.abs(event.deltaY) + + if (isPan) { + if (state.span >= 1) return clampZoom(state) + const delta = event.shiftKey ? event.deltaY : event.deltaX + // Positive delta → pan right (increase offset) + const panFraction = (delta / 400) * state.span + return clampZoom({ + span: state.span, + offset: state.offset + panFraction, + }) + } + + // Zoom in (deltaY < 0) / out (deltaY > 0) around cursor + const direction = event.deltaY < 0 ? -1 : event.deltaY > 0 ? 1 : 0 + if (direction === 0) return clampZoom(state) + + const nextSpan = clamp(state.span * (1 + direction * ZOOM_STEP), MIN_ZOOM_SPAN, 1) + if (Math.abs(nextSpan - state.span) < 1e-9) return clampZoom(state) + + // Keep the point under the cursor fixed in data space. + // windowStart is offset (domain fraction); dataPos = windowStart + ratio * span + const windowStart = state.offset + const dataPos = windowStart + ratio * state.span + const nextStart = dataPos - ratio * nextSpan + + return clampZoom({ + span: nextSpan, + offset: nextStart, + }) +} + +/** + * Slice an array using zoom state. Cursor-centered windowing — not always "last N". + */ +export function sliceByZoom(items: readonly T[], state: ZoomState): T[] { + if (!items.length) return [] + const zoom = clampZoom(state) + if (zoom.span >= 0.999) return [...items] + + const count = Math.max(1, Math.ceil(items.length * zoom.span)) + const maxStart = Math.max(0, items.length - count) + // offset is window start in [0, 1-span] domain → map to index + const start = Math.min(maxStart, Math.max(0, Math.round(zoom.offset * items.length))) + return items.slice(start, start + count) +} + +/** Map a client X position within an element to 0–1 ratio. */ +export function clientXRatio(clientX: number, element: HTMLElement | null | undefined): number { + if (!element) return 0.5 + const rect = element.getBoundingClientRect() + if (rect.width <= 0) return 0.5 + return clamp((clientX - rect.left) / rect.width, 0, 1) +} diff --git a/frontend/src/i18n/locales/en/admin/settings.ts b/frontend/src/i18n/locales/en/admin/settings.ts index 499fd4fe2..f9aa3f898 100644 --- a/frontend/src/i18n/locales/en/admin/settings.ts +++ b/frontend/src/i18n/locales/en/admin/settings.ts @@ -16,12 +16,21 @@ export default { features: { channelMonitor: { title: 'Channel Monitor', - description: 'Periodically probe configured channels and surface availability / latency to users. Turning it off stops the scheduler and returns an empty list on the user page.', + description: 'Choose either V1 active probes or V2 passive usage monitoring. When disabled, both background jobs stop and the user entry is hidden.', configureLink: 'Configure monitors in Channel Management > Channel Monitor', enabled: 'Enable Channel Monitor', - enabledHint: 'Disabling stops background checks; existing history is preserved.', + enabledHint: 'Disabling stops both the V1 scheduler and V2 aggregation; existing config and history are kept.', + mode: 'Monitor mode', + modeHint: 'Default is V1 (active probes). Switch to V2 only when you want passive aggregation; only one implementation can be active at a time.', + modeV2: 'V2 passive monitoring', + modeV1: 'V1 active probes', + modeV2Hint: 'Opt-in: aggregates health metrics from real gateway traffic without upstream probe traffic. V1 probes stop while V2 is active.', + modeV1Hint: 'Default: runs scheduled upstream health checks for configured channel monitors (probe traffic).', defaultInterval: 'Default check interval (seconds)', - defaultIntervalHint: 'Pre-fills the interval when creating a new monitor; each monitor can override it. Range 15 – 3600.', + defaultIntervalHint: 'V1 only: default interval for new monitors (overridable per monitor). Range 15 – 3600 seconds.', + hideThroughput: 'Hide throughput rates from users (RPM / TPM)', + hideThroughputHint: + 'When on, the user Channel Monitor page and user APIs omit RPM and TPM so fleet volume cannot be reverse-estimated from rates × window. Admins still see full metrics. Error rates, latency, and cache rates remain visible.', }, availableChannels: { title: 'Available Channels', diff --git a/frontend/src/i18n/locales/en/channelMonitorV2.ts b/frontend/src/i18n/locales/en/channelMonitorV2.ts new file mode 100644 index 000000000..1d94a154d --- /dev/null +++ b/frontend/src/i18n/locales/en/channelMonitorV2.ts @@ -0,0 +1,147 @@ +/** Channel Monitor V2 (user + admin passive monitor UI) */ +export default { + channelMonitorV2: { + title: 'Channel Monitor', + updating: 'Updating data', + updatedTo: 'Updated to {time}', + partialCoverage: 'Partial historical coverage', + bootstrap: { + title: 'Building historical monitor data', + description: + 'On first enable, passive aggregation silently fills the 90m, 24h, 7d, and 30d windows in the background. All ranges become complete once this finishes.', + progress: '{percent}% complete', + working: 'Aggregating in the background…', + }, + timeRange: 'Time range', + clearFilters: 'Reset', + refreshingFilters: 'Filters changed; refreshing matrix, trend, and details…', + switchingData: 'Switching filtered data…', + summaryAria: 'Selected range summary', + loadFailed: 'Failed to load channel monitor', + detailLoadFailed: 'Failed to load channel monitor details', + otherModels: 'Other models', + ignored: 'Ignored', + currentUser: 'Current user', + ranges: { '90m': '90m', '24h': '24h', '7d': '7d', '30d': '30d' }, + filters: { + platform: 'Platform', allPlatforms: 'All', group: 'Group', allGroups: 'All', model: 'Model', allModels: 'All', + empty: 'No options', selectedCount: '{count}', labelValue: '{label}: {value}' + }, + groupBy: { + label: 'Group by', platform: 'Platform', platformGroup: 'Platform / Group', platformModel: 'Platform / Model', platformGroupModel: 'Platform / Group / Model' + }, + trendView: { label: 'Trend view', pulse: 'Pulse matrix', line: 'Line chart' }, + healthMode: { label: 'Health display', overall: 'Overall', success: 'Error rate', ttft: 'First token', cache: 'Cache rate' }, + tabs: { aria: 'Detail dimension', models: 'Models', errors: 'Error reasons', users: 'User ranking' }, + metrics: { + rpm: 'RPM', + tpm: 'TPM', + tps: 'Tokens/s', + rpmDetail: 'Requests per minute', + tpmDetail: 'Tokens per minute', + tpsDetail: 'Derived as TPM ÷ 60', + errorRate: 'Error rate', + ttft: 'First token', + ttftP50: 'First token P50', + durationP50: 'Duration P50', + cacheRate: 'Cache rate', + cacheDetail: 'Read cache share', + successRate: 'Success rate', + successRateValue: 'Success rate {value}', + errorRateValue: 'Error rate {value}', + rpmValue: 'RPM {value}', + tpmValue: 'TPM {value}', + tpsValue: 'Tokens/s {value}', + ttftValue: 'First token {value}', + durationValue: 'Duration {value}', + cacheRateValue: 'Cache rate {value}', + }, + table: { platformModel: 'Platform / Model', rank: 'Rank', user: 'User' }, + empty: { title: 'No data to display', description: 'Try changing the time range or filters' }, + bucket: { minutes: '{count}-minute buckets', hours: '{count}-hour buckets', days: '{count}-day buckets' }, + matrix: { + title: 'Availability trend', description: 'Each row is a channel dimension and each block is an aggregate interval; hover for details', wheelZoom: 'Scroll over blocks to zoom in (narrower range, wider blocks)', wheelZoomX: 'Scroll over blocks to zoom in (narrower range, wider blocks)', dimension: 'Channel dimension', emptyTitle: 'No matrix data for the selected window', legendAria: 'Health score legend', bad: 'Bad', good: 'Good', healthyLegend: 'Healthy (≥80)', warningLegend: 'Watch (50–79)', criticalLegend: 'Critical (<50)', unknownLegend: 'No traffic / insufficient samples', noTraffic: 'No traffic in this interval', noTrafficAt: '{time} · no traffic', scoreLine: 'Health score {score}', resetZoom: 'Reset zoom' + }, + chart: { + title: 'Availability trend', description: 'Smoothed trend: error rate · first token P50 · cache rate', emptyTitle: 'No trend data for the selected window', errorLegend: 'Error rate (left axis %)', cacheLegend: 'Cache rate (left axis %)', ttftLegend: 'First token P50 (right axis)', errorDataset: 'Error rate trend %', cacheDataset: 'Cache rate trend %', ttftDataset: 'First token trend P50 (ms)', percentAxis: 'Rate %', resetZoom: 'Reset zoom' + }, + errorDetail: { http: 'HTTP {code}', upstream: 'Upstream {code}', noMessage: 'No error message', empty: 'Category rates only (sample messages are admin-only)' }, + errorCategories: { + content_policy: 'Content policy', authentication: 'Authentication', context_limit: 'Context limit', invalid_request: 'Invalid request', model_unsupported: 'Unsupported model', group_access: 'Group access', quota_or_balance: 'Quota or balance', account_pool_unavailable: 'Account pool unavailable', rate_or_capacity: 'Rate or capacity', timeout: 'Timeout', transport_or_stream: 'Transport or stream', upstream_forbidden: 'Upstream forbidden', not_found: 'Not found', client_cancelled: 'Client cancelled', upstream_5xx: 'Upstream 5xx', internal: 'Internal', other: 'Other' + }, + rank: { + gold: 'Rank 1 gold', + silver: 'Rank 2 silver', + bronze: 'Rank 3 bronze', + place: 'Rank {n}', + unranked: 'Unranked', + }, + settings: { + title: 'V2 data monitor config', + description: + 'Configure passive usage aggregation dimensions (platform / model / group) and refresh cadence. Health colors and details on the user /monitor page show rates, RPM, and TPM — not absolute request volume.', + save: 'Save', + loading: 'Loading…', + loadFailed: 'Failed to load V2 config', + saveSuccess: 'V2 monitor config saved', + saveFailed: 'Failed to save V2 config', + modeBanner: + 'System mode is currently {mode}. V2 minute aggregation will not run; this config can be prepared now and takes effect after switching to {modeV2}. Change mode under System Settings → Feature switches.', + modeClosed: 'Channel monitor disabled', + modeV1: 'V1 active probes', + modeV2: 'V2 passive monitoring', + enableTitle: 'Enable V2 aggregation', + enableHint: + 'Applies when system mode is V2. Turning this off only stops this config’s aggregation; the system mode switch remains under Feature switches.', + refreshTitle: 'Aggregation interval', + refreshHint: 'Affects matrix time granularity and refresh cadence', + refreshAria: 'Aggregation interval', + platformsTitle: 'Platforms and models', + platformsHint: + 'Leave empty = show all real model names; when filled, only listed models get their own rows and the rest roll into “Other”', + modelsPlaceholder: 'Empty = all real models; or list popular models (rest → Other)', + badgeAllModels: 'All models', + badgeOther: '+ Other', + groupsTitle: 'Monitored groups', + groupsSelected: '{count} groups selected', + groupsAll: 'All groups', + groupsEmpty: 'No groups available', + errorsTitle: 'Error categories and ignores', + errorsHint: + 'Checked “ignore” categories are excluded from error rate and health score, but still appear greyed in the error breakdown. Unmatched errors roll into “Other”.', + ignoredSummary: 'Ignored {ignored} categories · counted in error rate {counted} categories', + healthTitle: 'Health thresholds', + healthHint: + 'Controls user-facing color bands and overall score. Defaults are tolerant so small error rates or low cache do not immediately show as unhealthy.', + fields: { + minimumSample: 'Minimum samples', + warningError: 'Error rate watch %', + criticalError: 'Error rate critical %', + targetTtft: 'TTFT target ms', + warningTtft: 'TTFT watch ms', + criticalTtft: 'TTFT critical ms', + warningCache: 'Cache rate watch %', + criticalCache: 'Cache rate critical %', + }, + namedModelsEmpty: 'Platform model lists are empty: every real model name will be shown (not folded into “Other”).', + namedModelsCount: 'Showing {count} named model dimensions; unlisted models fold into per-platform “Other”.', + userContractTitle: 'User-facing display contract', + userContract: { + health: 'Health color weights: error rate 60% + first-token P50 20% + cache rate 20% (thresholds configurable above)', + trend: 'Trend can switch between pulse matrix and line chart (error · cache · first token)', + latency: 'Latency shows AVG · P50 · P90; absolute request / error counts are not shown', + models: 'Empty model lists show real names and never dump everything into “Other”', + }, + }, + admin: { + descriptionV1: + 'System mode is V1 active probes: manage probe monitors and run checks now; V2 aggregation does not run.', + descriptionV2: + 'System mode is V2 passive monitoring: configure aggregation dimensions; V1 active probes do not run.', + tabAria: 'Monitor management', + tabV2: 'V2 data monitor config', + tabV1Active: 'V1 active probes', + tabV1History: 'V1 history (probes not active in current mode)', + }, + }, +} diff --git a/frontend/src/i18n/locales/en/index.ts b/frontend/src/i18n/locales/en/index.ts index a855df543..ac2867bfd 100644 --- a/frontend/src/i18n/locales/en/index.ts +++ b/frontend/src/i18n/locales/en/index.ts @@ -1,6 +1,7 @@ import landing from './landing' import common from './common' import dashboard from './dashboard' +import channelMonitorV2 from './channelMonitorV2' import batchImage from './batchImage' import admin from './admin' import misc from './misc' @@ -9,6 +10,7 @@ export default { ...landing, ...common, ...dashboard, + ...channelMonitorV2, ...batchImage, admin, ...misc, diff --git a/frontend/src/i18n/locales/zh/admin/settings.ts b/frontend/src/i18n/locales/zh/admin/settings.ts index 5a8d69d2d..f82884ff7 100644 --- a/frontend/src/i18n/locales/zh/admin/settings.ts +++ b/frontend/src/i18n/locales/zh/admin/settings.ts @@ -16,12 +16,21 @@ export default { features: { channelMonitor: { title: '渠道监控', - description: '定期对配置的渠道发起健康检查,向用户展示可用性与延迟。关闭后调度器停止扫描,用户端列表为空。', + description: '启用后在 V1 主动探测与 V2 被动用量监控中二选一。关闭后两种模式的后台任务均停止,用户端入口隐藏。', configureLink: '前往 渠道管理 > 渠道监控 配置监控项', enabled: '启用渠道监控', - enabledHint: '关闭后后台不再执行定时检测,已有数据保留。', + enabledHint: '关闭后 V1 调度器与 V2 聚合均停止;已有配置与历史保留。', + mode: '监控模式', + modeHint: '默认 V1(主动探测)。仅在需要被动聚合时切换到 V2;同一时间只能启用一种实现。', + modeV2: 'V2 被动监控', + modeV1: 'V1 主动探测', + modeV2Hint: '需主动选择:基于真实网关流量聚合健康指标,不向上游发送探活请求;启用期间 V1 探测停止。', + modeV1Hint: '默认模式:按配置的渠道监控项定时发起上游健康检查(产生探测流量)。', defaultInterval: '默认检测间隔(秒)', - defaultIntervalHint: '新建渠道监控时表单的默认值,可被单个渠道覆盖。范围 15 – 3600 秒。', + defaultIntervalHint: '仅 V1 模式使用:新建渠道监控时表单的默认值,可被单个渠道覆盖。范围 15 – 3600 秒。', + hideThroughput: '对用户隐藏吞吐速率(RPM / TPM)', + hideThroughputHint: + '开启后,用户端渠道监控页面与用户 API 不返回 RPM/TPM,避免用「速率 × 时间窗」反推集群规模。管理员仍可见完整指标;错误率、延迟、缓存率照常展示。', }, availableChannels: { title: '可用渠道', diff --git a/frontend/src/i18n/locales/zh/channelMonitorV2.ts b/frontend/src/i18n/locales/zh/channelMonitorV2.ts new file mode 100644 index 000000000..1df03acbd --- /dev/null +++ b/frontend/src/i18n/locales/zh/channelMonitorV2.ts @@ -0,0 +1,142 @@ +/** Channel Monitor V2 (user + admin passive monitor UI) */ +export default { + channelMonitorV2: { + title: '渠道监控', + updating: '正在更新数据', + updatedTo: '更新至 {time}', + partialCoverage: '部分历史覆盖', + bootstrap: { + title: '正在补齐历史监控数据', + description: + '首次启用被动监控时,系统会在后台静默聚合 90 分钟、24 小时、7 天与 30 天窗口;完成后可切换全部时间范围。', + progress: '进度 {percent}%', + working: '后台聚合中…', + }, + timeRange: '时间范围', + clearFilters: '重置', + refreshingFilters: '筛选条件已变化,正在刷新矩阵、趋势和明细…', + switchingData: '正在切换筛选数据…', + summaryAria: '筛选范围整体汇总', + loadFailed: '渠道监控加载失败', + detailLoadFailed: '渠道监控明细加载失败', + otherModels: '其他模型', + ignored: '忽略', + currentUser: '当前用户', + ranges: { '90m': '90m', '24h': '24h', '7d': '7d', '30d': '30d' }, + filters: { + platform: '平台', allPlatforms: '全部', group: '分组', allGroups: '全部', model: '模型', allModels: '全部', + empty: '暂无可选项', selectedCount: '{count} 项', labelValue: '{label}:{value}' + }, + groupBy: { + label: '展示维度', platform: '平台', platformGroup: '平台 / 分组', platformModel: '平台 / 模型', platformGroupModel: '平台 / 分组 / 模型' + }, + trendView: { label: '趋势视图', pulse: '色块矩阵', line: '折线图' }, + healthMode: { label: '健康显示', overall: '综合', success: '错误率', ttft: '首 Token', cache: '缓存率' }, + tabs: { aria: '明细维度', models: '模型', errors: '错误原因', users: '用户排行' }, + metrics: { + rpm: 'RPM', + tpm: 'TPM', + tps: '每秒 Token', + rpmDetail: '每分钟请求数', + tpmDetail: '每分钟 Token 数', + tpsDetail: '由 TPM ÷ 60 换算', + errorRate: '错误率', + ttft: '首 Token', + ttftP50: '首 Token P50', + durationP50: '请求时长 P50', + cacheRate: '缓存率', + cacheDetail: '读缓存占比', + successRate: '成功率', + successRateValue: '成功率 {value}', + errorRateValue: '错误率 {value}', + rpmValue: 'RPM {value}', + tpmValue: 'TPM {value}', + tpsValue: '每秒 Token {value}', + ttftValue: '首 Token {value}', + durationValue: '请求时长 {value}', + cacheRateValue: '缓存率 {value}', + }, + table: { platformModel: '平台 / 模型', rank: '排名', user: '用户' }, + empty: { title: '没有可展示的数据', description: '尝试调整时间范围或筛选条件' }, + bucket: { minutes: '{count} 分钟粒度', hours: '{count} 小时粒度', days: '{count} 天粒度' }, + matrix: { + title: '可用性趋势', description: '每行是一种渠道组合,每个色块代表一个统计区间;悬停查看明细', wheelZoom: '在色块上滚轮放大(区间变窄、色块变宽)', wheelZoomX: '在色块上滚轮放大(区间变窄、色块变宽)', dimension: '渠道维度', emptyTitle: '当前筛选窗口没有矩阵数据', legendAria: '健康分数图例', bad: '差', good: '好', healthyLegend: '健康 (≥80)', warningLegend: '需关注 (50–79)', criticalLegend: '异常 (<50)', unknownLegend: '无流量 / 样本不足', noTraffic: '该区间无流量', noTrafficAt: '{time} · 无流量', scoreLine: '健康分 {score}', resetZoom: '重置缩放' + }, + chart: { + title: '可用性趋势', description: '平滑趋势:错误率 · 首 Token P50 · 缓存率', emptyTitle: '当前筛选窗口没有趋势数据', errorLegend: '错误率(左轴 %)', cacheLegend: '缓存率(左轴 %)', ttftLegend: '首 Token P50(右轴)', errorDataset: '错误率趋势 %', cacheDataset: '缓存率趋势 %', ttftDataset: '首 Token 趋势 P50 (ms)', percentAxis: '比率 %', resetZoom: '重置缩放' + }, + errorDetail: { http: 'HTTP {code}', upstream: '上游 {code}', noMessage: '无错误消息', empty: '仅展示分类占比(样本消息仅管理员可见)' }, + errorCategories: { + 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: '上游 5xx', internal: '内部错误', other: '其他' + }, + rank: { + gold: '第 1 名 金', + silver: '第 2 名 银', + bronze: '第 3 名 铜', + place: '第 {n} 名', + unranked: '未上榜', + }, + settings: { + title: 'V2 数据监控配置', + description: + '配置被动用量汇总维度(平台 / 模型 / 分组)与刷新频率。健康色与明细在用户端 /monitor 以比例、RPM/TPM 展示,不暴露绝对请求量。', + save: '保存', + loading: '加载中...', + loadFailed: 'V2 配置加载失败', + saveSuccess: 'V2 监控配置已保存', + saveFailed: 'V2 配置保存失败', + modeBanner: + '当前系统设置为 {mode}。V2 分钟聚合不会运行;此处配置可预先保存,切换到 {modeV2} 后立即生效。可在系统设置 → 功能开关调整。', + modeClosed: '渠道监控已关闭', + modeV1: 'V1 主动探测', + modeV2: 'V2 被动监控', + enableTitle: '启用 V2 汇总', + enableHint: '在系统模式为 V2 时生效;关闭后仅停止本配置的汇总,系统模式开关仍在「功能开关」', + refreshTitle: '汇总频率', + refreshHint: '影响矩阵时间粒度与刷新节奏', + refreshAria: '汇总频率', + platformsTitle: '平台与模型', + platformsHint: '留空 = 展示全部真实模型名;填写后仅名单内单独成行,其余归入「其他」', + modelsPlaceholder: '留空=全部真实模型;或填写主流模型名单(其余归其他)', + badgeAllModels: '全部模型', + badgeOther: '+ 其他', + groupsTitle: '监控分组', + groupsSelected: '已选择 {count} 个分组', + groupsAll: '全部分组', + groupsEmpty: '没有可选择的分组', + errorsTitle: '错误分类与忽略', + errorsHint: + '勾选「忽略」的类别不计入错误率与健康分,仍在错误原因列表中以灰色显示并标记忽略。未匹配的错误归入「其他」。', + ignoredSummary: '已忽略 {ignored} 类 · 计入错误率 {counted} 类', + healthTitle: '健康阈值', + healthHint: '控制用户端色块和整体评分。默认阈值较宽松,避免少量错误或低缓存率立即显示异常。', + fields: { + minimumSample: '最小样本数', + warningError: '错误率关注 %', + criticalError: '错误率异常 %', + targetTtft: 'TTFT 目标 ms', + warningTtft: 'TTFT 关注 ms', + criticalTtft: 'TTFT 异常 ms', + warningCache: '缓存率关注 %', + criticalCache: '缓存率异常 %', + }, + namedModelsEmpty: '各平台模型列表为空:将展示全部真实模型名(不归入「其他」)。', + namedModelsCount: '将展示 {count} 个命名模型维度;名单外模型归入各平台「其他」。', + userContractTitle: '用户端展示约定', + userContract: { + health: '健康色三指标:错误率 60% + 首 Token P50 20% + 缓存率 20%(阈值可在上方配置)', + trend: '趋势可切换色块矩阵 / 折线图(错误率 · 缓存率 · 首 Token)', + latency: '延迟展示 AVG · P50 · P90;不展示绝对请求数 / 错误数', + models: '模型列表留空时展示真实模型名,不会全部归入「其他」', + }, + }, + admin: { + descriptionV1: '当前系统设置为 V1 主动探测:可管理监控项并立即检测;V2 聚合不会运行。', + descriptionV2: '当前系统设置为 V2 被动监控:配置聚合维度;V1 主动探测不会运行。', + tabAria: '监控管理', + tabV2: 'V2 数据监控配置', + tabV1Active: 'V1 主动探测', + tabV1History: 'V1 历史(当前模式未启用探测)', + }, + }, +} diff --git a/frontend/src/i18n/locales/zh/index.ts b/frontend/src/i18n/locales/zh/index.ts index a855df543..ac2867bfd 100644 --- a/frontend/src/i18n/locales/zh/index.ts +++ b/frontend/src/i18n/locales/zh/index.ts @@ -1,6 +1,7 @@ import landing from './landing' import common from './common' import dashboard from './dashboard' +import channelMonitorV2 from './channelMonitorV2' import batchImage from './batchImage' import admin from './admin' import misc from './misc' @@ -9,6 +10,7 @@ export default { ...landing, ...common, ...dashboard, + ...channelMonitorV2, ...batchImage, admin, ...misc, diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 008bd8181..a1a248a5a 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -262,7 +262,11 @@ export interface PublicSettings { account_quota_notify_enabled: boolean balance_low_notify_threshold: number channel_monitor_enabled: boolean + /** Exclusive mode: v1 active probes or v2 passive aggregation. Default v2. */ + channel_monitor_mode?: 'v1' | 'v2' channel_monitor_default_interval_seconds: number + /** When true, user monitor hides RPM/TPM so scale cannot be reverse-estimated. */ + channel_monitor_hide_throughput?: boolean available_channels_enabled: boolean model_plaza_enabled: boolean model_plaza_require_auth: boolean diff --git a/frontend/src/utils/featureFlags.ts b/frontend/src/utils/featureFlags.ts index 5004c068b..0fc9d8a3a 100644 --- a/frontend/src/utils/featureFlags.ts +++ b/frontend/src/utils/featureFlags.ts @@ -68,9 +68,9 @@ * `makeSidebarFlag(flag)` returns a `() => boolean | undefined` compatible with * `AppSidebar.NavItem.featureFlag`, where `false` hides the menu entry. */ - import { useAppStore } from '@/stores/app' import type { PublicSettings } from '@/types' +import { DEFAULT_INTERVAL_SECONDS } from '@/constants/channelMonitor' export type FeatureFlagMode = 'opt-in' | 'opt-out' @@ -152,3 +152,37 @@ export function isFeatureFlagEnabled(flag: FeatureFlagDefinition): boolean { export function makeSidebarFlag(flag: FeatureFlagDefinition): () => boolean { return () => isFeatureFlagEnabled(flag) } + +/** True when channel monitor feature flag is enabled. */ +export function isChannelMonitorRouteEnabled(): boolean { + return isFeatureFlagEnabled(FeatureFlags.channelMonitor) +} + +export type ChannelMonitorMode = 'v1' | 'v2' + +/** Exclusive channel-monitor implementation. Invalid/missing → v1 (opt-in to v2). */ +export function getChannelMonitorMode(): ChannelMonitorMode { + const appStore = useAppStore() + const mode = appStore.cachedPublicSettings?.channel_monitor_mode + return mode === 'v2' ? 'v2' : 'v1' +} + +export function isChannelMonitorV1Mode(): boolean { + return isChannelMonitorRouteEnabled() && getChannelMonitorMode() === 'v1' +} + +export function isChannelMonitorV2Mode(): boolean { + return isChannelMonitorRouteEnabled() && getChannelMonitorMode() === 'v2' +} + +export function getChannelMonitorRefreshIntervalSeconds(): number { + const appStore = useAppStore() + const configured = appStore.cachedPublicSettings?.channel_monitor_default_interval_seconds + return configured && configured > 0 ? configured : DEFAULT_INTERVAL_SECONDS +} + +/** Hide RPM/TPM on user-facing monitor (scale privacy). Admin always shows full metrics. */ +export function isChannelMonitorThroughputHidden(): boolean { + const appStore = useAppStore() + return Boolean(appStore.cachedPublicSettings?.channel_monitor_hide_throughput) +} diff --git a/frontend/src/views/admin/ChannelMonitorView.vue b/frontend/src/views/admin/ChannelMonitorView.vue index d58a842d7..d1bb148e8 100644 --- a/frontend/src/views/admin/ChannelMonitorView.vue +++ b/frontend/src/views/admin/ChannelMonitorView.vue @@ -1,6 +1,55 @@