channel_monitor_v2.go 38 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099
  1. package service
  2. import (
  3. "context"
  4. "errors"
  5. "fmt"
  6. "sort"
  7. "strings"
  8. "time"
  9. )
  10. const (
  11. ChannelMonitorV2OtherModel = "__other__"
  12. ChannelMonitorV2TaxonomyVersion = 1
  13. )
  14. var (
  15. ErrChannelMonitorV2InvalidRange = errors.New("invalid channel monitor v2 range")
  16. ErrChannelMonitorV2InvalidGroupBy = errors.New("invalid channel monitor v2 group_by")
  17. ErrChannelMonitorV2InvalidConfig = errors.New("invalid channel monitor v2 config")
  18. ErrChannelMonitorV2ConfigConflict = errors.New("channel monitor v2 config was modified")
  19. )
  20. type ChannelMonitorV2GroupBy string
  21. const (
  22. ChannelMonitorV2GroupByPlatform ChannelMonitorV2GroupBy = "platform"
  23. ChannelMonitorV2GroupByPlatformGroup ChannelMonitorV2GroupBy = "platform_group"
  24. ChannelMonitorV2GroupByPlatformModel ChannelMonitorV2GroupBy = "platform_model"
  25. ChannelMonitorV2GroupByPlatformGroupModel ChannelMonitorV2GroupBy = "platform_group_model"
  26. )
  27. type ChannelMonitorV2PlatformConfig struct {
  28. Platform string `json:"platform"`
  29. Enabled bool `json:"enabled"`
  30. Models []string `json:"models"`
  31. }
  32. type ChannelMonitorV2Config struct {
  33. Version int `json:"version"`
  34. Enabled bool `json:"enabled"`
  35. RefreshIntervalSeconds int `json:"refresh_interval_seconds"`
  36. Platforms []ChannelMonitorV2PlatformConfig `json:"platforms"`
  37. GroupIDs []int64 `json:"group_ids"`
  38. HealthThresholds ChannelMonitorV2HealthThresholds `json:"health_thresholds"`
  39. // IgnoredErrorCategories are excluded from error_rate / health scoring.
  40. // They still appear in the error breakdown with ignored=true (greyed in UI).
  41. // Unknown categories always roll into "other" via the taxonomy classifier.
  42. IgnoredErrorCategories []string `json:"ignored_error_categories"`
  43. UpdatedAt time.Time `json:"updated_at"`
  44. UpdatedBy *int64 `json:"updated_by,omitempty"`
  45. }
  46. // ChannelMonitorV2ErrorCategories is the ordered, versioned taxonomy used by the
  47. // classifier and admin settings UI. Unmatched errors become "other".
  48. var ChannelMonitorV2ErrorCategories = []string{
  49. "content_policy",
  50. "authentication",
  51. "context_limit",
  52. "invalid_request",
  53. "model_unsupported",
  54. "group_access",
  55. "quota_or_balance",
  56. "account_pool_unavailable",
  57. "rate_or_capacity",
  58. "timeout",
  59. "transport_or_stream",
  60. "upstream_forbidden",
  61. "not_found",
  62. "client_cancelled",
  63. "upstream_5xx",
  64. "internal",
  65. "other",
  66. }
  67. type ChannelMonitorV2Filter struct {
  68. Range string
  69. Platforms []string
  70. GroupIDs []int64
  71. // AllowedGroupIDs is the authenticated viewer's server-derived group scope.
  72. // RestrictGroups distinguishes an ordinary user with no allowed groups from
  73. // the unrestricted admin/configured scope represented by an empty slice.
  74. AllowedGroupIDs []int64
  75. RestrictGroups bool
  76. Models []string
  77. Start time.Time
  78. End time.Time
  79. Bucket time.Duration
  80. }
  81. type ChannelMonitorV2Metric struct {
  82. SuccessRequests int64 `json:"success_requests"`
  83. ErrorRequests int64 `json:"error_requests"`
  84. RequestCount int64 `json:"request_count"`
  85. InputTokens int64 `json:"input_tokens"`
  86. OutputTokens int64 `json:"output_tokens"`
  87. CacheCreationTokens int64 `json:"cache_creation_tokens"`
  88. CacheReadTokens int64 `json:"cache_read_tokens"`
  89. TokenCount int64 `json:"token_count"`
  90. RPM float64 `json:"rpm"`
  91. TPM float64 `json:"tpm"`
  92. ErrorRate float64 `json:"error_rate"`
  93. SuccessRate float64 `json:"success_rate"`
  94. CacheRate float64 `json:"cache_rate"`
  95. CacheRateNumerator int64 `json:"cache_rate_numerator"`
  96. CacheRateDenominator int64 `json:"cache_rate_denominator"`
  97. TTFT ChannelMonitorV2Latency `json:"ttft"`
  98. Duration ChannelMonitorV2Latency `json:"duration"`
  99. UpstreamAffectedRequests *int64 `json:"upstream_affected_requests,omitempty"`
  100. UpstreamAttemptCount *int64 `json:"upstream_attempt_count,omitempty"`
  101. }
  102. type ChannelMonitorV2Latency struct {
  103. SampleCount int64 `json:"sample_count"`
  104. P50Ms *int64 `json:"p50_ms"`
  105. P90Ms *int64 `json:"p90_ms"`
  106. P95Ms *int64 `json:"p95_ms"`
  107. AvgMs *float64 `json:"avg_ms"`
  108. }
  109. type ChannelMonitorV2Health struct {
  110. Overall string `json:"overall"`
  111. ErrorRate string `json:"error_rate"`
  112. TTFT string `json:"ttft"`
  113. Cache string `json:"cache"`
  114. // Score is 0–100 when samples are sufficient; omitted/null when unknown.
  115. // Overall blends error-rate, TTFT p50, and cache rate (weights in Thresholds).
  116. Score *float64 `json:"score,omitempty"`
  117. ErrorRateScore *float64 `json:"error_rate_score,omitempty"`
  118. TTFTScore *float64 `json:"ttft_score,omitempty"`
  119. CacheScore *float64 `json:"cache_score,omitempty"`
  120. MinimumSample int64 `json:"minimum_sample"`
  121. Thresholds ChannelMonitorV2HealthThresholds `json:"thresholds"`
  122. }
  123. type ChannelMonitorV2HealthThresholds struct {
  124. // MinimumSample is required before scoring request/latency/cache signals.
  125. MinimumSample int64 `json:"minimum_sample"`
  126. // WarningErrorRate / CriticalErrorRate map to discrete bands for legacy UI.
  127. WarningErrorRate float64 `json:"warning_error_rate"`
  128. CriticalErrorRate float64 `json:"critical_error_rate"`
  129. // TargetTTFTMs is the primary TTFT p50 budget (ms). At or below this is full TTFT score.
  130. TargetTTFTMs int64 `json:"target_ttft_ms"`
  131. WarningTTFTMs int64 `json:"warning_ttft_ms"`
  132. CriticalTTFTMs int64 `json:"critical_ttft_ms"`
  133. // WarningCacheRate / CriticalCacheRate: cache rate below these → warning/critical bands.
  134. // Higher cache rate is better; defaults 20% warning / 5% critical.
  135. WarningCacheRate float64 `json:"warning_cache_rate"`
  136. CriticalCacheRate float64 `json:"critical_cache_rate"`
  137. // ErrorWeight + TTFTWeight + CacheWeight should sum to 1.0.
  138. ErrorWeight float64 `json:"error_weight"`
  139. TTFTWeight float64 `json:"ttft_weight"`
  140. CacheWeight float64 `json:"cache_weight"`
  141. }
  142. type ChannelMonitorV2Coverage struct {
  143. RequestedStart time.Time `json:"requested_start"`
  144. // RequestedEnd is the exclusive upper bound of the UI-selected window
  145. // (filter.End). Charts/matrices should plot [RequestedStart, RequestedEnd)
  146. // even when CoverageStart is later (partial backfill).
  147. RequestedEnd time.Time `json:"requested_end"`
  148. CoverageStart time.Time `json:"coverage_start"`
  149. DataThrough time.Time `json:"data_through"`
  150. ComputedAt time.Time `json:"computed_at"`
  151. AggregationLagSeconds int64 `json:"aggregation_lag_seconds"`
  152. // CoverageComplete is true when aggregated history reaches the requested
  153. // window start (backfill / product coverage for this range is done).
  154. // Trailing lag — data_through behind filter.End because UI ends align to
  155. // the next bucket and may sit slightly in the future — does not make this
  156. // false once history is covered.
  157. CoverageComplete bool `json:"coverage_complete"`
  158. BucketSeconds int `json:"bucket_seconds"`
  159. // Bootstrap is set while the first-upgrade historical backfill toward the
  160. // product windows (90m / 24h / 7d / 30d) is still running. Omitted or
  161. // inactive once the 30d UI range is fully covered; longer retention (90d)
  162. // may continue silently without a progress banner.
  163. Bootstrap *ChannelMonitorV2Bootstrap `json:"bootstrap,omitempty"`
  164. }
  165. // ChannelMonitorV2Bootstrap reports first-upgrade aggregation progress for the UI.
  166. // Progress is measured against the longest product window (30d), not full 90d retention.
  167. type ChannelMonitorV2Bootstrap struct {
  168. // Active is true while historical backfill has not yet covered the 30d product window.
  169. Active bool `json:"active"`
  170. // ProgressPercent is 0–100 of history covered from now back toward TargetStart.
  171. ProgressPercent int `json:"progress_percent"`
  172. // CoveredFrom is the earliest minute already recomputed (backfill cursor).
  173. CoveredFrom time.Time `json:"covered_from,omitempty"`
  174. // TargetStart is now−30d (product bootstrap goal for 90m/24h/7d/30d).
  175. TargetStart time.Time `json:"target_start,omitempty"`
  176. }
  177. // ChannelMonitorV2AggregationWatermark is the durable aggregator cursor state.
  178. type ChannelMonitorV2AggregationWatermark struct {
  179. UsageCoverageStart time.Time
  180. ErrorCoverageStart time.Time
  181. DataThrough time.Time
  182. LastSuccessfulAt time.Time
  183. // BackfillCursor is the earliest start already recomputed; zero when never set.
  184. BackfillCursor time.Time
  185. // HasData is true once any successful recompute wrote data_through.
  186. HasData bool
  187. }
  188. type ChannelMonitorV2TrendPoint struct {
  189. BucketStart time.Time `json:"bucket_start"`
  190. Metrics ChannelMonitorV2Metric `json:"metrics"`
  191. Health ChannelMonitorV2Health `json:"health"`
  192. }
  193. type ChannelMonitorV2Snapshot struct {
  194. Config ChannelMonitorV2Config `json:"config"`
  195. Coverage ChannelMonitorV2Coverage `json:"coverage"`
  196. Metrics ChannelMonitorV2Metric `json:"metrics"`
  197. Health ChannelMonitorV2Health `json:"health"`
  198. Trend []ChannelMonitorV2TrendPoint `json:"trend"`
  199. }
  200. type ChannelMonitorV2Dimension struct {
  201. Value string `json:"value"`
  202. Label string `json:"label"`
  203. Platform string `json:"platform,omitempty"`
  204. RequestCount int64 `json:"request_count"`
  205. }
  206. type ChannelMonitorV2GroupDimension struct {
  207. ID int64 `json:"id"`
  208. Name string `json:"name"`
  209. Platform string `json:"platform,omitempty"`
  210. RequestCount int64 `json:"request_count"`
  211. }
  212. type ChannelMonitorV2Dimensions struct {
  213. Platforms []ChannelMonitorV2Dimension `json:"platforms"`
  214. Groups []ChannelMonitorV2GroupDimension `json:"groups"`
  215. Models []ChannelMonitorV2Dimension `json:"models"`
  216. }
  217. type ChannelMonitorV2ModelRow struct {
  218. Platform string `json:"platform"`
  219. Model string `json:"model"`
  220. Metrics ChannelMonitorV2Metric `json:"metrics"`
  221. Health ChannelMonitorV2Health `json:"health"`
  222. }
  223. type ChannelMonitorV2MatrixRow struct {
  224. Platform string `json:"platform"`
  225. GroupID *int64 `json:"group_id,omitempty"`
  226. GroupName string `json:"group_name,omitempty"`
  227. Model string `json:"model,omitempty"`
  228. Metrics ChannelMonitorV2Metric `json:"metrics"`
  229. Health ChannelMonitorV2Health `json:"health"`
  230. Buckets []ChannelMonitorV2TrendPoint `json:"buckets"`
  231. }
  232. type ChannelMonitorV2Matrix struct {
  233. GroupBy ChannelMonitorV2GroupBy `json:"group_by"`
  234. Coverage ChannelMonitorV2Coverage `json:"coverage"`
  235. Items []ChannelMonitorV2MatrixRow `json:"items"`
  236. }
  237. type ChannelMonitorV2ErrorRow struct {
  238. Category string `json:"category"`
  239. Count int64 `json:"count"`
  240. Rate float64 `json:"rate"`
  241. Details []ChannelMonitorV2ErrorDetail `json:"details,omitempty"`
  242. // Ignored is true when this category is excluded from error_rate scoring
  243. // via config.ignored_error_categories. Still shown in breakdown (UI greys it).
  244. Ignored bool `json:"ignored"`
  245. }
  246. type ChannelMonitorV2ErrorDetail struct {
  247. Platform string `json:"platform,omitempty"`
  248. Model string `json:"model,omitempty"`
  249. ErrorType string `json:"error_type,omitempty"`
  250. StatusCode int `json:"status_code,omitempty"`
  251. UpstreamStatusCode int `json:"upstream_status_code,omitempty"`
  252. Message string `json:"message,omitempty"`
  253. Count int64 `json:"count"`
  254. }
  255. type ChannelMonitorV2UserRow struct {
  256. UserID *int64 `json:"user_id,omitempty"`
  257. Rank int `json:"rank"`
  258. Email string `json:"email,omitempty"`
  259. Username string `json:"username,omitempty"`
  260. DisplayLabel string `json:"display_label"`
  261. IsSelf bool `json:"is_self"`
  262. CanDrilldown bool `json:"can_drilldown"`
  263. Metrics ChannelMonitorV2Metric `json:"metrics"`
  264. }
  265. type ChannelMonitorV2List[T any] struct {
  266. Coverage ChannelMonitorV2Coverage `json:"coverage"`
  267. Items []T `json:"items"`
  268. }
  269. type ChannelMonitorV2Repository interface {
  270. GetConfig(ctx context.Context) (*ChannelMonitorV2Config, error)
  271. UpdateConfig(ctx context.Context, config ChannelMonitorV2Config, expectedVersion int) (*ChannelMonitorV2Config, error)
  272. GetDimensions(ctx context.Context, filter ChannelMonitorV2Filter, config ChannelMonitorV2Config) (*ChannelMonitorV2Dimensions, error)
  273. GetSnapshot(ctx context.Context, filter ChannelMonitorV2Filter, config ChannelMonitorV2Config, includeAdmin bool) (*ChannelMonitorV2Snapshot, error)
  274. GetModels(ctx context.Context, filter ChannelMonitorV2Filter, config ChannelMonitorV2Config, includeAdmin bool) (*ChannelMonitorV2List[ChannelMonitorV2ModelRow], error)
  275. GetMatrix(ctx context.Context, filter ChannelMonitorV2Filter, config ChannelMonitorV2Config, groupBy ChannelMonitorV2GroupBy, includeAdmin bool) (*ChannelMonitorV2Matrix, error)
  276. // GetErrors loads category rates. When includeAdmin is false, implementations
  277. // must omit error Details (no ops_error_logs sample scan) for privacy.
  278. GetErrors(ctx context.Context, filter ChannelMonitorV2Filter, config ChannelMonitorV2Config, includeAdmin bool) (*ChannelMonitorV2List[ChannelMonitorV2ErrorRow], error)
  279. GetUsers(ctx context.Context, filter ChannelMonitorV2Filter, config ChannelMonitorV2Config, includeAdmin bool) (*ChannelMonitorV2List[ChannelMonitorV2UserRow], error)
  280. // GetAggregationWatermark loads durable backfill / coverage cursors for the
  281. // passive aggregator (and bootstrap progress). Missing row → zero value, nil error.
  282. GetAggregationWatermark(ctx context.Context) (*ChannelMonitorV2AggregationWatermark, error)
  283. RecomputeRange(ctx context.Context, start, end time.Time) error
  284. }
  285. // ChannelMonitorV2BootstrapProductWindow is the longest UI range that must be
  286. // filled on first upgrade before the bootstrap banner disappears (30d).
  287. const ChannelMonitorV2BootstrapProductWindow = 30 * 24 * time.Hour
  288. // ChannelMonitorV2BootstrapProgress builds the optional UI progress payload.
  289. // now and coveredFrom should be UTC minute-truncated when possible.
  290. // When history is complete for the 30d product window, returns nil (no banner).
  291. func ChannelMonitorV2BootstrapProgress(now, coveredFrom time.Time, hasData bool) *ChannelMonitorV2Bootstrap {
  292. now = now.UTC().Truncate(time.Minute)
  293. targetStart := now.Add(-ChannelMonitorV2BootstrapProductWindow)
  294. if !hasData {
  295. return &ChannelMonitorV2Bootstrap{
  296. Active: true,
  297. ProgressPercent: 0,
  298. TargetStart: targetStart,
  299. }
  300. }
  301. if coveredFrom.IsZero() {
  302. // Have data_through but no cursor yet — treat as barely started.
  303. return &ChannelMonitorV2Bootstrap{
  304. Active: true,
  305. ProgressPercent: 0,
  306. TargetStart: targetStart,
  307. }
  308. }
  309. coveredFrom = coveredFrom.UTC().Truncate(time.Minute)
  310. if !coveredFrom.After(targetStart) {
  311. // 30d product windows fully covered; hide progress (90d retention may continue).
  312. return nil
  313. }
  314. total := now.Sub(targetStart).Seconds()
  315. if total <= 0 {
  316. return nil
  317. }
  318. done := now.Sub(coveredFrom).Seconds()
  319. if done < 0 {
  320. done = 0
  321. }
  322. if done > total {
  323. done = total
  324. }
  325. // Round to nearest percent; any non-zero history shows at least 1% so the
  326. // bar moves after the first 2h bootstrap tick (2h/30d ≈ 0.3%).
  327. pct := int((done/total)*100 + 0.5)
  328. if done > 0 && pct < 1 {
  329. pct = 1
  330. }
  331. if pct > 100 {
  332. pct = 100
  333. }
  334. if pct >= 100 {
  335. // Cursor still slightly after target due to rounding — keep active until
  336. // coveredFrom <= targetStart so the banner does not flash 100% then vanish.
  337. pct = 99
  338. }
  339. return &ChannelMonitorV2Bootstrap{
  340. Active: true,
  341. ProgressPercent: pct,
  342. CoveredFrom: coveredFrom,
  343. TargetStart: targetStart,
  344. }
  345. }
  346. type ChannelMonitorV2Service struct {
  347. repo ChannelMonitorV2Repository
  348. settings channelMonitorRuntimeReader
  349. now func() time.Time
  350. }
  351. func NewChannelMonitorV2Service(repo ChannelMonitorV2Repository) *ChannelMonitorV2Service {
  352. return &ChannelMonitorV2Service{repo: repo, now: func() time.Time { return time.Now().UTC() }}
  353. }
  354. // SetRuntimeReader wires optional settings for privacy flags (hide throughput).
  355. func (s *ChannelMonitorV2Service) SetRuntimeReader(r channelMonitorRuntimeReader) {
  356. if s == nil {
  357. return
  358. }
  359. s.settings = r
  360. }
  361. func (s *ChannelMonitorV2Service) hideThroughputForViewer(ctx context.Context, admin bool) bool {
  362. if admin {
  363. return false
  364. }
  365. // Privacy is fail-closed: an absent or unavailable settings reader must not
  366. // expose fleet-scale rates to ordinary users.
  367. if s == nil || s.settings == nil {
  368. return true
  369. }
  370. return s.settings.GetChannelMonitorRuntime(ctx).HideThroughput
  371. }
  372. func (s *ChannelMonitorV2Service) hideUserRankingForViewer(ctx context.Context, admin bool) bool {
  373. if admin {
  374. return false
  375. }
  376. // Missing reader keeps the current ranking tab visible. Unlike throughput,
  377. // ranking is already public and must stay on until an operator turns it off.
  378. if s == nil || s.settings == nil {
  379. return false
  380. }
  381. return s.settings.GetChannelMonitorRuntime(ctx).HideUserRanking
  382. }
  383. func (s *ChannelMonitorV2Service) GetConfig(ctx context.Context) (*ChannelMonitorV2Config, error) {
  384. return s.repo.GetConfig(ctx)
  385. }
  386. func (s *ChannelMonitorV2Service) getEnabledConfig(ctx context.Context) (*ChannelMonitorV2Config, error) {
  387. cfg, err := s.repo.GetConfig(ctx)
  388. if err != nil {
  389. return nil, err
  390. }
  391. if cfg == nil || !cfg.Enabled {
  392. return nil, ErrChannelMonitorDisabled
  393. }
  394. return cfg, nil
  395. }
  396. func (s *ChannelMonitorV2Service) UpdateConfig(ctx context.Context, cfg ChannelMonitorV2Config, expectedVersion int, actorID int64) (*ChannelMonitorV2Config, error) {
  397. if err := normalizeChannelMonitorV2Config(&cfg); err != nil {
  398. return nil, err
  399. }
  400. cfg.UpdatedBy = &actorID
  401. return s.repo.UpdateConfig(ctx, cfg, expectedVersion)
  402. }
  403. func (s *ChannelMonitorV2Service) ParseFilter(rangeValue string, platforms, models []string, groupIDs []int64) (ChannelMonitorV2Filter, error) {
  404. now := s.now().UTC()
  405. var window, bucket time.Duration
  406. switch strings.TrimSpace(rangeValue) {
  407. case "", "90m":
  408. rangeValue, window, bucket = "90m", 90*time.Minute, 5*time.Minute
  409. case "24h":
  410. window, bucket = 24*time.Hour, time.Hour
  411. case "7d":
  412. window, bucket = 7*24*time.Hour, 12*time.Hour
  413. case "30d":
  414. window, bucket = 30*24*time.Hour, 24*time.Hour
  415. default:
  416. return ChannelMonitorV2Filter{}, fmt.Errorf("%w: %s", ErrChannelMonitorV2InvalidRange, rangeValue)
  417. }
  418. start, end := now.Add(-window), now
  419. if bucket > time.Minute {
  420. // Align display windows to a fixed number of whole buckets. The trailing
  421. // bucket may point slightly into the future; SQL simply has no future rows,
  422. // while the current partial bucket can still be shown and recomputed often.
  423. end = now.Truncate(bucket).Add(bucket)
  424. start = end.Add(-window)
  425. }
  426. return ChannelMonitorV2Filter{
  427. Range: rangeValue, Platforms: normalizeStringSet(platforms), Models: normalizeStringSet(models), GroupIDs: normalizeInt64Set(groupIDs),
  428. Start: start, End: end, Bucket: bucket,
  429. }, nil
  430. }
  431. func (s *ChannelMonitorV2Service) Dimensions(ctx context.Context, filter ChannelMonitorV2Filter) (*ChannelMonitorV2Dimensions, error) {
  432. cfg, err := s.getEnabledConfig(ctx)
  433. if err != nil {
  434. return nil, err
  435. }
  436. dims, err := s.repo.GetDimensions(ctx, filter, *cfg)
  437. if err != nil {
  438. return nil, err
  439. }
  440. // Dimension request_count is operational volume; strip for non-admin callers
  441. // at the API edge. Dimensions is shared by user/admin routes — redaction is
  442. // applied in the handler for user routes only, so keep raw here.
  443. return dims, nil
  444. }
  445. func (s *ChannelMonitorV2Service) Snapshot(ctx context.Context, filter ChannelMonitorV2Filter, admin bool) (*ChannelMonitorV2Snapshot, error) {
  446. cfg, err := s.getEnabledConfig(ctx)
  447. if err != nil {
  448. return nil, err
  449. }
  450. snap, err := s.repo.GetSnapshot(ctx, filter, *cfg, admin)
  451. if err != nil {
  452. return nil, err
  453. }
  454. if !admin && snap != nil {
  455. redactChannelMonitorV2Snapshot(snap, s.hideThroughputForViewer(ctx, admin))
  456. }
  457. return snap, nil
  458. }
  459. func (s *ChannelMonitorV2Service) Models(ctx context.Context, filter ChannelMonitorV2Filter, admin bool) (*ChannelMonitorV2List[ChannelMonitorV2ModelRow], error) {
  460. cfg, err := s.getEnabledConfig(ctx)
  461. if err != nil {
  462. return nil, err
  463. }
  464. list, err := s.repo.GetModels(ctx, filter, *cfg, admin)
  465. if err != nil {
  466. return nil, err
  467. }
  468. if !admin && list != nil {
  469. hideTP := s.hideThroughputForViewer(ctx, admin)
  470. for i := range list.Items {
  471. redactChannelMonitorV2Metric(&list.Items[i].Metrics, hideTP)
  472. }
  473. }
  474. return list, nil
  475. }
  476. func (s *ChannelMonitorV2Service) Matrix(ctx context.Context, filter ChannelMonitorV2Filter, groupBy ChannelMonitorV2GroupBy, admin bool) (*ChannelMonitorV2Matrix, error) {
  477. if !groupBy.Valid() {
  478. return nil, fmt.Errorf("%w: %s", ErrChannelMonitorV2InvalidGroupBy, groupBy)
  479. }
  480. cfg, err := s.getEnabledConfig(ctx)
  481. if err != nil {
  482. return nil, err
  483. }
  484. matrix, err := s.repo.GetMatrix(ctx, filter, *cfg, groupBy, admin)
  485. if err != nil {
  486. return nil, err
  487. }
  488. if !admin && matrix != nil {
  489. hideTP := s.hideThroughputForViewer(ctx, admin)
  490. for i := range matrix.Items {
  491. redactChannelMonitorV2Metric(&matrix.Items[i].Metrics, hideTP)
  492. for j := range matrix.Items[i].Buckets {
  493. redactChannelMonitorV2Metric(&matrix.Items[i].Buckets[j].Metrics, hideTP)
  494. }
  495. }
  496. }
  497. return matrix, nil
  498. }
  499. func ParseChannelMonitorV2GroupBy(value string) (ChannelMonitorV2GroupBy, error) {
  500. groupBy := ChannelMonitorV2GroupBy(strings.TrimSpace(value))
  501. if groupBy == "" {
  502. // Default presentation: platform / group (matches operator mental model).
  503. groupBy = ChannelMonitorV2GroupByPlatformGroup
  504. }
  505. if !groupBy.Valid() {
  506. return "", fmt.Errorf("%w: %s", ErrChannelMonitorV2InvalidGroupBy, value)
  507. }
  508. return groupBy, nil
  509. }
  510. func (g ChannelMonitorV2GroupBy) Valid() bool {
  511. switch g {
  512. case ChannelMonitorV2GroupByPlatform, ChannelMonitorV2GroupByPlatformGroup, ChannelMonitorV2GroupByPlatformModel, ChannelMonitorV2GroupByPlatformGroupModel:
  513. return true
  514. default:
  515. return false
  516. }
  517. }
  518. func (s *ChannelMonitorV2Service) Errors(ctx context.Context, filter ChannelMonitorV2Filter) (*ChannelMonitorV2List[ChannelMonitorV2ErrorRow], error) {
  519. return s.ErrorsForViewer(ctx, filter, false)
  520. }
  521. // ErrorsForViewer returns the error breakdown.
  522. // Non-admin callers receive category rates + ignored flags only: absolute Count
  523. // is zeroed and Details (upstream messages / status codes / volume) are omitted.
  524. func (s *ChannelMonitorV2Service) ErrorsForViewer(ctx context.Context, filter ChannelMonitorV2Filter, admin bool) (*ChannelMonitorV2List[ChannelMonitorV2ErrorRow], error) {
  525. cfg, err := s.getEnabledConfig(ctx)
  526. if err != nil {
  527. return nil, err
  528. }
  529. list, err := s.repo.GetErrors(ctx, filter, *cfg, admin)
  530. if err != nil {
  531. return nil, err
  532. }
  533. if !admin && list != nil {
  534. for i := range list.Items {
  535. list.Items[i].Count = 0
  536. list.Items[i].Details = nil
  537. }
  538. }
  539. return list, nil
  540. }
  541. // RedactChannelMonitorV2Dimensions clears absolute request counts on filter chips.
  542. func RedactChannelMonitorV2Dimensions(dims *ChannelMonitorV2Dimensions) {
  543. if dims == nil {
  544. return
  545. }
  546. for i := range dims.Platforms {
  547. dims.Platforms[i].RequestCount = 0
  548. }
  549. for i := range dims.Models {
  550. dims.Models[i].RequestCount = 0
  551. }
  552. for i := range dims.Groups {
  553. dims.Groups[i].RequestCount = 0
  554. }
  555. }
  556. func redactChannelMonitorV2Snapshot(snap *ChannelMonitorV2Snapshot, hideThroughput bool) {
  557. if snap == nil {
  558. return
  559. }
  560. redactChannelMonitorV2Metric(&snap.Metrics, hideThroughput)
  561. for i := range snap.Trend {
  562. redactChannelMonitorV2Metric(&snap.Trend[i].Metrics, hideThroughput)
  563. }
  564. // Public snapshot only needs display thresholds + refresh cadence, not
  565. // operational allow-lists (group_ids, model inventories, ignored categories).
  566. redactChannelMonitorV2PublicConfig(&snap.Config)
  567. }
  568. // redactChannelMonitorV2PublicConfig strips operator-policy fields from config
  569. // embedded in user-facing snapshots. Full config remains on admin /config.
  570. func redactChannelMonitorV2PublicConfig(cfg *ChannelMonitorV2Config) {
  571. if cfg == nil {
  572. return
  573. }
  574. cfg.GroupIDs = nil
  575. cfg.IgnoredErrorCategories = nil
  576. cfg.UpdatedBy = nil
  577. for i := range cfg.Platforms {
  578. cfg.Platforms[i].Models = nil
  579. }
  580. }
  581. // redactChannelMonitorV2Metric zeros absolute volume counters while keeping rates
  582. // (error_rate, success_rate, cache_rate) and latency percentiles.
  583. // When hideThroughput is true, also zeros RPM/TPM so users cannot reverse-estimate
  584. // fleet scale from rate × window length.
  585. func redactChannelMonitorV2Metric(m *ChannelMonitorV2Metric, hideThroughput bool) {
  586. if m == nil {
  587. return
  588. }
  589. m.SuccessRequests = 0
  590. m.ErrorRequests = 0
  591. m.RequestCount = 0
  592. m.InputTokens = 0
  593. m.OutputTokens = 0
  594. m.CacheCreationTokens = 0
  595. m.CacheReadTokens = 0
  596. m.TokenCount = 0
  597. m.CacheRateNumerator = 0
  598. m.CacheRateDenominator = 0
  599. // Latency sample_count is also a volume signal.
  600. m.TTFT.SampleCount = 0
  601. m.Duration.SampleCount = 0
  602. m.UpstreamAffectedRequests = nil
  603. m.UpstreamAttemptCount = nil
  604. if hideThroughput {
  605. m.RPM = 0
  606. m.TPM = 0
  607. }
  608. }
  609. func (s *ChannelMonitorV2Service) Users(ctx context.Context, filter ChannelMonitorV2Filter, viewerID int64, admin bool) (*ChannelMonitorV2List[ChannelMonitorV2UserRow], error) {
  610. cfg, err := s.getEnabledConfig(ctx)
  611. if err != nil {
  612. return nil, err
  613. }
  614. if s.hideUserRankingForViewer(ctx, admin) {
  615. return &ChannelMonitorV2List[ChannelMonitorV2UserRow]{Items: []ChannelMonitorV2UserRow{}}, nil
  616. }
  617. result, err := s.repo.GetUsers(ctx, filter, *cfg, admin)
  618. if err != nil {
  619. return nil, err
  620. }
  621. if result == nil {
  622. result = &ChannelMonitorV2List[ChannelMonitorV2UserRow]{}
  623. }
  624. selfIndex := -1
  625. for i := range result.Items {
  626. result.Items[i].Rank = i + 1
  627. if result.Items[i].UserID != nil && *result.Items[i].UserID == viewerID {
  628. selfIndex = i
  629. result.Items[i].IsSelf = true
  630. }
  631. }
  632. // Viewer with no traffic in this window is still shown (and highlighted) so
  633. // ranking always answers "where am I?" — not only when already in the top list.
  634. if selfIndex < 0 && viewerID > 0 {
  635. id := viewerID
  636. selfRow := ChannelMonitorV2UserRow{
  637. UserID: &id,
  638. Rank: 0, // unranked / no traffic in window
  639. IsSelf: true,
  640. CanDrilldown: true,
  641. DisplayLabel: "Me",
  642. Metrics: ChannelMonitorV2Metric{},
  643. }
  644. result.Items = append(result.Items, selfRow)
  645. selfIndex = len(result.Items) - 1
  646. }
  647. result.Items = channelMonitorV2TopUsersWithSelf(result.Items, selfIndex, 10)
  648. hideTP := s.hideThroughputForViewer(ctx, admin)
  649. if admin {
  650. // Keep identity for admin; still mark self for UI highlight.
  651. for i := range result.Items {
  652. if result.Items[i].UserID != nil && *result.Items[i].UserID == viewerID {
  653. result.Items[i].IsSelf = true
  654. if result.Items[i].DisplayLabel == "" || result.Items[i].DisplayLabel == "Me" {
  655. // Prefer real label when available from repo.
  656. if result.Items[i].Username != "" {
  657. result.Items[i].DisplayLabel = result.Items[i].Username
  658. } else if result.Items[i].Email != "" {
  659. result.Items[i].DisplayLabel = result.Items[i].Email
  660. } else {
  661. result.Items[i].DisplayLabel = "Me"
  662. }
  663. }
  664. }
  665. }
  666. return result, nil
  667. }
  668. for i := range result.Items {
  669. redactChannelMonitorV2Metric(&result.Items[i].Metrics, hideTP)
  670. }
  671. for i := range result.Items {
  672. row := &result.Items[i]
  673. if row.UserID != nil && *row.UserID == viewerID {
  674. row.IsSelf, row.CanDrilldown, row.DisplayLabel = true, true, "Me"
  675. continue
  676. }
  677. row.UserID, row.Email, row.Username, row.CanDrilldown = nil, "", "", false
  678. row.DisplayLabel = fmt.Sprintf("Other user #%d", i+1)
  679. }
  680. return result, nil
  681. }
  682. func channelMonitorV2TopUsersWithSelf(items []ChannelMonitorV2UserRow, selfIndex int, limit int) []ChannelMonitorV2UserRow {
  683. if limit <= 0 {
  684. return items
  685. }
  686. if len(items) <= limit {
  687. return items
  688. }
  689. out := append([]ChannelMonitorV2UserRow(nil), items[:limit]...)
  690. if selfIndex >= limit && selfIndex < len(items) {
  691. out = append(out, items[selfIndex])
  692. }
  693. return out
  694. }
  695. func normalizeChannelMonitorV2Config(cfg *ChannelMonitorV2Config) error {
  696. if cfg.RefreshIntervalSeconds == 0 {
  697. cfg.RefreshIntervalSeconds = 300
  698. }
  699. if cfg.RefreshIntervalSeconds != 60 && cfg.RefreshIntervalSeconds != 300 {
  700. return fmt.Errorf("%w: refresh_interval_seconds must be 60 or 300", ErrChannelMonitorV2InvalidConfig)
  701. }
  702. var err error
  703. cfg.GroupIDs, err = normalizeChannelMonitorV2GroupIDs(cfg.GroupIDs)
  704. if err != nil {
  705. return err
  706. }
  707. cfg.IgnoredErrorCategories = normalizeChannelMonitorV2IgnoredCategories(cfg.IgnoredErrorCategories)
  708. cfg.HealthThresholds = NormalizeChannelMonitorV2HealthThresholds(cfg.HealthThresholds)
  709. seen := make(map[string]struct{}, len(cfg.Platforms))
  710. for i := range cfg.Platforms {
  711. p := &cfg.Platforms[i]
  712. p.Platform = strings.ToLower(strings.TrimSpace(p.Platform))
  713. if p.Platform == "" {
  714. return fmt.Errorf("%w: empty platform", ErrChannelMonitorV2InvalidConfig)
  715. }
  716. if _, ok := seen[p.Platform]; ok {
  717. return fmt.Errorf("%w: duplicate platform %s", ErrChannelMonitorV2InvalidConfig, p.Platform)
  718. }
  719. seen[p.Platform] = struct{}{}
  720. p.Models = normalizeStringSet(p.Models)
  721. }
  722. sort.Slice(cfg.Platforms, func(i, j int) bool { return cfg.Platforms[i].Platform < cfg.Platforms[j].Platform })
  723. return nil
  724. }
  725. // DefaultChannelMonitorV2IgnoredErrorCategories are factory defaults for
  726. // ignored_error_categories: excluded from error_rate / health scoring only.
  727. // Operators can clear or extend via admin config.
  728. var DefaultChannelMonitorV2IgnoredErrorCategories = []string{
  729. "authentication",
  730. "client_cancelled",
  731. "content_policy",
  732. "context_limit",
  733. "group_access",
  734. "model_unsupported",
  735. "not_found",
  736. "quota_or_balance",
  737. }
  738. func DefaultChannelMonitorV2HealthThresholds() ChannelMonitorV2HealthThresholds {
  739. return ChannelMonitorV2HealthThresholds{
  740. MinimumSample: 50,
  741. WarningErrorRate: 0.05,
  742. CriticalErrorRate: 0.20,
  743. TargetTTFTMs: 3000,
  744. WarningTTFTMs: 3000,
  745. CriticalTTFTMs: 10000,
  746. // A zero/zero cache threshold means cache misses do not affect health
  747. // until an operator explicitly configures cache scoring.
  748. WarningCacheRate: 0,
  749. CriticalCacheRate: 0,
  750. ErrorWeight: 0.60,
  751. TTFTWeight: 0.20,
  752. CacheWeight: 0.20,
  753. }
  754. }
  755. func NormalizeChannelMonitorV2HealthThresholds(in ChannelMonitorV2HealthThresholds) ChannelMonitorV2HealthThresholds {
  756. def := DefaultChannelMonitorV2HealthThresholds()
  757. if in.MinimumSample <= 0 {
  758. in.MinimumSample = def.MinimumSample
  759. }
  760. if in.MinimumSample < 1 {
  761. in.MinimumSample = 1
  762. }
  763. if in.MinimumSample > 10000 {
  764. in.MinimumSample = 10000
  765. }
  766. if in.WarningErrorRate <= 0 {
  767. in.WarningErrorRate = def.WarningErrorRate
  768. }
  769. if in.CriticalErrorRate <= 0 {
  770. in.CriticalErrorRate = def.CriticalErrorRate
  771. }
  772. if in.CriticalErrorRate < in.WarningErrorRate {
  773. in.CriticalErrorRate = in.WarningErrorRate
  774. }
  775. if in.TargetTTFTMs <= 0 {
  776. in.TargetTTFTMs = def.TargetTTFTMs
  777. }
  778. if in.WarningTTFTMs <= 0 {
  779. in.WarningTTFTMs = def.WarningTTFTMs
  780. }
  781. if in.WarningTTFTMs < in.TargetTTFTMs {
  782. in.WarningTTFTMs = in.TargetTTFTMs + 1
  783. }
  784. if in.CriticalTTFTMs <= 0 {
  785. in.CriticalTTFTMs = def.CriticalTTFTMs
  786. }
  787. if in.CriticalTTFTMs < in.WarningTTFTMs {
  788. in.CriticalTTFTMs = in.WarningTTFTMs
  789. }
  790. if in.WarningCacheRate < 0 {
  791. in.WarningCacheRate = 0
  792. }
  793. if in.CriticalCacheRate < 0 {
  794. in.CriticalCacheRate = 0
  795. }
  796. if in.WarningCacheRate > 1 {
  797. in.WarningCacheRate = 1
  798. }
  799. if in.CriticalCacheRate > 1 {
  800. in.CriticalCacheRate = 1
  801. }
  802. if in.CriticalCacheRate > in.WarningCacheRate {
  803. in.CriticalCacheRate = in.WarningCacheRate
  804. }
  805. if in.ErrorWeight <= 0 && in.TTFTWeight <= 0 && in.CacheWeight <= 0 {
  806. in.ErrorWeight, in.TTFTWeight, in.CacheWeight = def.ErrorWeight, def.TTFTWeight, def.CacheWeight
  807. }
  808. return in
  809. }
  810. func normalizeChannelMonitorV2IgnoredCategories(values []string) []string {
  811. allowed := make(map[string]struct{}, len(ChannelMonitorV2ErrorCategories))
  812. for _, c := range ChannelMonitorV2ErrorCategories {
  813. allowed[c] = struct{}{}
  814. }
  815. seen := make(map[string]struct{}, len(values))
  816. out := make([]string, 0, len(values))
  817. for _, value := range values {
  818. value = strings.ToLower(strings.TrimSpace(value))
  819. if value == "" {
  820. continue
  821. }
  822. if _, ok := allowed[value]; !ok {
  823. // Unknown labels are dropped so a typo cannot silently create a new category.
  824. continue
  825. }
  826. if _, ok := seen[value]; ok {
  827. continue
  828. }
  829. seen[value] = struct{}{}
  830. out = append(out, value)
  831. }
  832. sort.Strings(out)
  833. return out
  834. }
  835. // ChannelMonitorV2IgnoredCategorySet returns a set for O(1) membership checks.
  836. func ChannelMonitorV2IgnoredCategorySet(cfg ChannelMonitorV2Config) map[string]struct{} {
  837. set := make(map[string]struct{}, len(cfg.IgnoredErrorCategories))
  838. for _, c := range cfg.IgnoredErrorCategories {
  839. set[c] = struct{}{}
  840. }
  841. return set
  842. }
  843. func normalizeStringSet(values []string) []string {
  844. seen := make(map[string]struct{}, len(values))
  845. out := make([]string, 0, len(values))
  846. for _, value := range values {
  847. value = strings.TrimSpace(value)
  848. if value == "" {
  849. continue
  850. }
  851. if _, ok := seen[value]; ok {
  852. continue
  853. }
  854. seen[value] = struct{}{}
  855. out = append(out, value)
  856. }
  857. sort.Strings(out)
  858. return out
  859. }
  860. func normalizeInt64Set(values []int64) []int64 {
  861. seen := make(map[int64]struct{}, len(values))
  862. out := make([]int64, 0, len(values))
  863. for _, value := range values {
  864. if value <= 0 {
  865. continue
  866. }
  867. if _, ok := seen[value]; ok {
  868. continue
  869. }
  870. seen[value] = struct{}{}
  871. out = append(out, value)
  872. }
  873. sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
  874. return out
  875. }
  876. func normalizeChannelMonitorV2GroupIDs(values []int64) ([]int64, error) {
  877. seen := make(map[int64]struct{}, len(values))
  878. out := make([]int64, 0, len(values))
  879. for _, value := range values {
  880. if value <= 0 {
  881. return nil, fmt.Errorf("%w: group_ids must be positive", ErrChannelMonitorV2InvalidConfig)
  882. }
  883. if _, ok := seen[value]; ok {
  884. continue
  885. }
  886. seen[value] = struct{}{}
  887. out = append(out, value)
  888. }
  889. sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
  890. return out, nil
  891. }
  892. func ChannelMonitorV2HealthFor(metrics ChannelMonitorV2Metric) ChannelMonitorV2Health {
  893. return ChannelMonitorV2HealthForWithThresholds(metrics, DefaultChannelMonitorV2HealthThresholds())
  894. }
  895. func ChannelMonitorV2HealthForWithThresholds(metrics ChannelMonitorV2Metric, thresholds ChannelMonitorV2HealthThresholds) ChannelMonitorV2Health {
  896. thresholds = NormalizeChannelMonitorV2HealthThresholds(thresholds)
  897. result := ChannelMonitorV2Health{
  898. Overall: "unknown", ErrorRate: "unknown", TTFT: "unknown", Cache: "unknown",
  899. MinimumSample: thresholds.MinimumSample, Thresholds: thresholds,
  900. }
  901. type scored struct {
  902. score float64
  903. weight float64
  904. band string
  905. }
  906. parts := make([]scored, 0, 3)
  907. if metrics.RequestCount >= result.MinimumSample {
  908. s := errorRateScore(metrics.ErrorRate, thresholds.CriticalErrorRate)
  909. result.ErrorRateScore = &s
  910. result.ErrorRate = healthBand(metrics.ErrorRate, thresholds.WarningErrorRate, thresholds.CriticalErrorRate)
  911. parts = append(parts, scored{score: s, weight: thresholds.ErrorWeight, band: result.ErrorRate})
  912. }
  913. // Prefer p50 for TTFT scoring; fall back to p95 only if p50 is missing.
  914. if metrics.TTFT.SampleCount >= result.MinimumSample {
  915. var ttftMs *int64
  916. if metrics.TTFT.P50Ms != nil {
  917. ttftMs = metrics.TTFT.P50Ms
  918. } else if metrics.TTFT.P95Ms != nil {
  919. ttftMs = metrics.TTFT.P95Ms
  920. }
  921. if ttftMs != nil {
  922. s := ttftP50Score(float64(*ttftMs), float64(thresholds.TargetTTFTMs), float64(thresholds.CriticalTTFTMs))
  923. result.TTFTScore = &s
  924. result.TTFT = healthBand(float64(*ttftMs), float64(thresholds.WarningTTFTMs), float64(thresholds.CriticalTTFTMs))
  925. parts = append(parts, scored{score: s, weight: thresholds.TTFTWeight, band: result.TTFT})
  926. }
  927. }
  928. // Cache: need a meaningful denominator; higher rate is better.
  929. if metrics.CacheRateDenominator >= result.MinimumSample {
  930. s := cacheRateScore(metrics.CacheRate)
  931. if thresholds.WarningCacheRate <= 0 && thresholds.CriticalCacheRate <= 0 {
  932. // A zero/zero cache threshold means "do not penalize cache misses".
  933. s = 100
  934. }
  935. result.CacheScore = &s
  936. // Invert for healthBand (lower is worse): use (1 - rate) against warning/critical floors.
  937. result.Cache = cacheRateBand(metrics.CacheRate, thresholds.WarningCacheRate, thresholds.CriticalCacheRate)
  938. parts = append(parts, scored{score: s, weight: thresholds.CacheWeight, band: result.Cache})
  939. }
  940. if len(parts) == 0 {
  941. return result
  942. }
  943. var weightSum, scoreSum float64
  944. for _, p := range parts {
  945. weightSum += p.weight
  946. scoreSum += p.weight * p.score
  947. }
  948. if weightSum <= 0 {
  949. return result
  950. }
  951. overall := scoreSum / weightSum
  952. result.Score = &overall
  953. result.Overall = scoreBand(overall)
  954. return result
  955. }
  956. // errorRateScore maps error rate to 0–100. 0% → 100; at/above critical → 0 (linear).
  957. func errorRateScore(errorRate, critical float64) float64 {
  958. if critical <= 0 {
  959. critical = 0.05
  960. }
  961. if errorRate <= 0 {
  962. return 100
  963. }
  964. if errorRate >= critical {
  965. return 0
  966. }
  967. return 100 * (1 - errorRate/critical)
  968. }
  969. // ttftP50Score maps TTFT p50 ms to 0–100.
  970. // At/below target → 100; at/above critical → 0; linear in between.
  971. func ttftP50Score(p50Ms, targetMs, criticalMs float64) float64 {
  972. if targetMs <= 0 {
  973. targetMs = 2500
  974. }
  975. if criticalMs <= targetMs {
  976. criticalMs = targetMs * 2.4
  977. }
  978. if p50Ms <= targetMs {
  979. return 100
  980. }
  981. if p50Ms >= criticalMs {
  982. return 0
  983. }
  984. return 100 * (1 - (p50Ms-targetMs)/(criticalMs-targetMs))
  985. }
  986. // cacheRateScore maps cache hit rate to 0–100 (higher is better, linear).
  987. func cacheRateScore(cacheRate float64) float64 {
  988. if cacheRate <= 0 {
  989. return 0
  990. }
  991. if cacheRate >= 1 {
  992. return 100
  993. }
  994. return 100 * cacheRate
  995. }
  996. // cacheRateBand: below critical → critical; below warning → warning; else healthy.
  997. func cacheRateBand(cacheRate, warning, critical float64) string {
  998. if cacheRate < critical {
  999. return "critical"
  1000. }
  1001. if cacheRate < warning {
  1002. return "warning"
  1003. }
  1004. return "healthy"
  1005. }
  1006. // scoreBand maps continuous 0–100 scores to coarse labels for legacy consumers.
  1007. func scoreBand(score float64) string {
  1008. switch {
  1009. case score >= 80:
  1010. return "healthy"
  1011. case score >= 50:
  1012. return "warning"
  1013. default:
  1014. return "critical"
  1015. }
  1016. }
  1017. func healthBand(value, warning, critical float64) string {
  1018. if value >= critical {
  1019. return "critical"
  1020. }
  1021. if value >= warning {
  1022. return "warning"
  1023. }
  1024. return "healthy"
  1025. }