From 7cd3dc85c3f8826064ec29766f2e20f100741e40 Mon Sep 17 00:00:00 2001 From: wucm667 Date: Thu, 8 Oct 2026 10:41:20 +0800 Subject: [PATCH] fix(channel-monitor): require request samples before health scoring --- .../internal/service/channel_monitor_v2.go | 3 + .../service/channel_monitor_v2_test.go | 79 +++++++++++++++++++ 2 files changed, 82 insertions(+) diff --git a/backend/internal/service/channel_monitor_v2.go b/backend/internal/service/channel_monitor_v2.go index cb2251de7a..d4aed72dcb 100644 --- a/backend/internal/service/channel_monitor_v2.go +++ b/backend/internal/service/channel_monitor_v2.go @@ -963,6 +963,9 @@ func ChannelMonitorV2HealthForWithThresholds(metrics ChannelMonitorV2Metric, thr Overall: "unknown", ErrorRate: "unknown", TTFT: "unknown", Cache: "unknown", MinimumSample: thresholds.MinimumSample, Thresholds: thresholds, } + if metrics.RequestCount < result.MinimumSample { + return result + } type scored struct { score float64 diff --git a/backend/internal/service/channel_monitor_v2_test.go b/backend/internal/service/channel_monitor_v2_test.go index d26f43eef2..c30f01cb74 100644 --- a/backend/internal/service/channel_monitor_v2_test.go +++ b/backend/internal/service/channel_monitor_v2_test.go @@ -252,6 +252,81 @@ func TestChannelMonitorV2HealthBlendsErrorTTFTAndCache(t *testing.T) { require.Nil(t, health.Score) } +func TestChannelMonitorV2HealthRequiresRequestSamples(t *testing.T) { + for _, requestCount := range []int64{0, 8, 9, 49} { + t.Run(fmt.Sprintf("requests_%d", requestCount), func(t *testing.T) { + thresholds := DefaultChannelMonitorV2HealthThresholds() + thresholds.WarningCacheRate = 0.85 + thresholds.CriticalCacheRate = 0.60 + metrics := ChannelMonitorV2Metric{SuccessRequests: requestCount, RequestCount: requestCount} + if requestCount > 0 { + metrics.CacheRateDenominator = 448 + } + health := ChannelMonitorV2HealthForWithThresholds(metrics, thresholds) + require.Equal(t, ChannelMonitorV2Health{ + Overall: "unknown", ErrorRate: "unknown", TTFT: "unknown", Cache: "unknown", + MinimumSample: 50, Thresholds: NormalizeChannelMonitorV2HealthThresholds(thresholds), + }, health) + }) + } +} + +func TestChannelMonitorV2HealthMinimumSampleBoundaries(t *testing.T) { + for _, test := range []struct { + name string + minimumSample int64 + wantMinimum int64 + }{ + {name: "configured", minimumSample: 20, wantMinimum: 20}, + {name: "configured_one", minimumSample: 1, wantMinimum: 1}, + {name: "normalized_zero", minimumSample: 0, wantMinimum: 50}, + {name: "normalized_negative", minimumSample: -1, wantMinimum: 50}, + {name: "normalized_maximum", minimumSample: 10001, wantMinimum: 10000}, + } { + t.Run(test.name, func(t *testing.T) { + thresholds := DefaultChannelMonitorV2HealthThresholds() + thresholds.MinimumSample = test.minimumSample + thresholds.WarningCacheRate = 0.85 + thresholds.CriticalCacheRate = 0.60 + p50 := int64(1000) + metrics := ChannelMonitorV2Metric{ + SuccessRequests: test.wantMinimum - 1, RequestCount: test.wantMinimum - 1, + CacheRateDenominator: 20000, + TTFT: ChannelMonitorV2Latency{SampleCount: 20000, P50Ms: &p50}, + } + health := ChannelMonitorV2HealthForWithThresholds(metrics, thresholds) + require.Equal(t, ChannelMonitorV2Health{ + Overall: "unknown", ErrorRate: "unknown", TTFT: "unknown", Cache: "unknown", + MinimumSample: test.wantMinimum, Thresholds: NormalizeChannelMonitorV2HealthThresholds(thresholds), + }, health) + + metrics.RequestCount = test.wantMinimum + metrics.SuccessRequests = test.wantMinimum + health = ChannelMonitorV2HealthForWithThresholds(metrics, thresholds) + require.Equal(t, test.wantMinimum, health.MinimumSample) + require.Equal(t, "healthy", health.ErrorRate) + require.Equal(t, "healthy", health.TTFT) + require.Equal(t, "critical", health.Cache) + require.NotNil(t, health.ErrorRateScore) + require.NotNil(t, health.TTFTScore) + require.NotNil(t, health.CacheScore) + require.NotNil(t, health.Score) + require.InDelta(t, 80.0, *health.Score, 0.01) + require.Equal(t, "healthy", health.Overall) + }) + } +} + +func TestChannelMonitorV2HealthSufficientHighErrorIsCritical(t *testing.T) { + health := ChannelMonitorV2HealthFor(ChannelMonitorV2Metric{ + RequestCount: 50, ErrorRequests: 50, ErrorRate: 1, + }) + require.Equal(t, "critical", health.ErrorRate) + require.Equal(t, "critical", health.Overall) + require.NotNil(t, health.Score) + require.Zero(t, *health.Score) +} + func TestChannelMonitorV2HealthLeavesMissingTTFTUnknown(t *testing.T) { health := ChannelMonitorV2HealthFor(ChannelMonitorV2Metric{ RequestCount: 200, @@ -452,6 +527,9 @@ func TestSnapshotRedactsPublicConfigPolicyFields(t *testing.T) { Metrics: ChannelMonitorV2Metric{RequestCount: 100, ErrorRate: 0.1, SuccessRate: 0.9, RPM: 5}, }, } + repo.snap.Health = ChannelMonitorV2HealthFor(repo.snap.Metrics) + wantHealth := repo.snap.Health + require.NotNil(t, wantHealth.Score) svc := NewChannelMonitorV2Service(repo) snap, err := svc.Snapshot(context.Background(), ChannelMonitorV2Filter{}, false) require.NoError(t, err) @@ -461,6 +539,7 @@ func TestSnapshotRedactsPublicConfigPolicyFields(t *testing.T) { require.Empty(t, snap.Config.Platforms[0].Models) require.Equal(t, 300, snap.Config.RefreshIntervalSeconds) require.Zero(t, snap.Metrics.RequestCount) + require.Equal(t, wantHealth, snap.Health) require.InDelta(t, 0.1, snap.Metrics.ErrorRate, 0.0001) require.Zero(t, snap.Metrics.RPM) }