88 "log/slog"
99 "net/http"
1010 "os"
11+ "slices"
1112 "sort"
1213 "strings"
1314 "sync"
@@ -96,7 +97,13 @@ func (m *Manager) ProbeAll(ctx context.Context) {
9697// a glance which source the dashboard will treat as authoritative.
9798// Categories with no available producer are silently skipped — they're
9899// just absent from this host, no log noise warranted.
100+ //
101+ // Override-validation warnings are emitted here (once per agent start)
102+ // rather than from inside ResolvePrimarySources, so the API endpoint
103+ // that calls Resolve on every request doesn't replay the same warning
104+ // on every dashboard poll.
99105func (m * Manager ) logPrimarySources () {
106+ m .ValidatePrimarySourceOverrides ()
100107 picks := m .ResolvePrimarySources ()
101108 if len (picks ) == 0 {
102109 return
@@ -537,6 +544,16 @@ var preferenceOrder = map[MetricCategory][]string{
537544 CategoryHTTPRequests : {"haproxy" , "nginx" , "apache" , "caddy" , "traefik" },
538545}
539546
547+ // categoryEnvVar names the env var that controls each metric category's
548+ // primary-source override. Kept as a map (rather than a switch) so
549+ // adding a new category requires only an entry here — and the
550+ // exhaustiveness test in source_test.go fails until that entry exists,
551+ // preventing the "(unknown category)" placeholder from ever appearing
552+ // in operator-facing warnings or SourceSelection.Reason strings.
553+ var categoryEnvVar = map [MetricCategory ]string {
554+ CategoryHTTPRequests : "GEARBOX_AGENT_HTTP_SOURCE" ,
555+ }
556+
540557// ResolvePrimarySources picks the primary metric source for each
541558// defined MetricCategory based on the probe table, the built-in
542559// preference order, and any operator overrides in deps.SourceOverrides.
@@ -554,11 +571,56 @@ var preferenceOrder = map[MetricCategory][]string{
554571// 3. If neither path finds anything, the category is omitted.
555572//
556573// Overrides that name an unknown gear, a not-Available gear, or a gear
557- // that doesn't produce data for this category log a warning at startup
558- // and fall through to auto-detection — operators templating env files
559- // across heterogeneous fleets shouldn't lose metrics because one host
560- // doesn't have the override's target installed.
574+ // that doesn't produce data for this category fall through to auto-
575+ // detection — operators templating env files across heterogeneous
576+ // fleets shouldn't lose metrics because one host doesn't have the
577+ // override's target installed. Warnings about mis-targeted overrides
578+ // are emitted **once at startup** by ValidatePrimarySourceOverrides,
579+ // not here, so the API endpoint that calls Resolve on every request
580+ // doesn't turn dashboard polling into log spam.
581+ //
582+ // The function is side-effect-free apart from reading the probe table,
583+ // safe to call concurrently from the capabilities API handler.
561584func (m * Manager ) ResolvePrimarySources () map [MetricCategory ]SourceSelection {
585+ picks , _ := m .resolvePrimarySources ()
586+ return picks
587+ }
588+
589+ // ValidatePrimarySourceOverrides re-runs the resolver and logs a
590+ // warning for each operator override that doesn't apply on this host
591+ // — unknown gear, not-Available gear, or gear that doesn't produce
592+ // the category in question. Called once from ProbeAll so operators
593+ // see actionable warnings in journalctl at startup without the
594+ // /api/v1/system/capabilities endpoint replaying them on every poll.
595+ //
596+ // Returns the number of override entries that failed validation,
597+ // purely so tests can assert without parsing log output. Production
598+ // callers can ignore the return value.
599+ func (m * Manager ) ValidatePrimarySourceOverrides () int {
600+ _ , invalid := m .resolvePrimarySources ()
601+ for _ , inv := range invalid {
602+ m .logger .Warn (inv .message ,
603+ "category" , inv .category ,
604+ "override" , inv .override ,
605+ "env_var" , categoryEnvVar [inv .category ])
606+ }
607+ return len (invalid )
608+ }
609+
610+ // overrideValidationFailure captures one mis-targeted override so
611+ // ValidatePrimarySourceOverrides can log it from a single place
612+ // (consistent slog key-set) and tests can count failures.
613+ type overrideValidationFailure struct {
614+ category MetricCategory
615+ override string
616+ message string
617+ }
618+
619+ // resolvePrimarySources does the actual work shared between Resolve
620+ // (silent) and Validate (logs warnings). Returns the picks plus any
621+ // override failures the caller may want to surface. Unexported so the
622+ // validation-vs-resolution split stays an implementation detail.
623+ func (m * Manager ) resolvePrimarySources () (map [MetricCategory ]SourceSelection , []overrideValidationFailure ) {
562624 probed := m .ProbeResults ()
563625
564626 // Build category-to-producers from the registered gears that
@@ -584,6 +646,7 @@ func (m *Manager) ResolvePrimarySources() map[MetricCategory]SourceSelection {
584646 }
585647
586648 out := make (map [MetricCategory ]SourceSelection )
649+ var failures []overrideValidationFailure
587650 for _ , cat := range AllMetricCategories () {
588651 available := producers [cat ]
589652 if len (available ) == 0 {
@@ -597,64 +660,62 @@ func (m *Manager) ResolvePrimarySources() map[MetricCategory]SourceSelection {
597660 ordered := orderByPreference (available , preferenceOrder [cat ])
598661
599662 // Operator override: must be available AND a registered
600- // producer. Fall through with a warning otherwise.
601- override := strings .ToLower (strings .TrimSpace (deps_override (m .deps , cat )))
663+ // producer. Capture validation failures for the caller to log,
664+ // but fall through to auto-detect either way.
665+ override := strings .ToLower (strings .TrimSpace (overrideForCategory (m .deps , cat )))
602666 if override != "" {
603667 if _ , isProducer := registeredFor [cat ][override ]; ! isProducer {
604- m .logger .Warn ("source override names a gear that doesn't produce this metric category; falling back to auto-detect" ,
605- "category" , cat ,
606- "override" , override ,
607- "env_var" , overrideEnvVarFor (cat ))
608- } else if ! containsString (ordered , override ) {
609- m .logger .Warn ("source override names an unavailable gear; falling back to auto-detect" ,
610- "category" , cat ,
611- "override" , override ,
612- "env_var" , overrideEnvVarFor (cat ))
668+ failures = append (failures , overrideValidationFailure {
669+ category : cat ,
670+ override : override ,
671+ message : "source override names a gear that doesn't produce this metric category; falling back to auto-detect" ,
672+ })
673+ } else if ! slices .Contains (ordered , override ) {
674+ failures = append (failures , overrideValidationFailure {
675+ category : cat ,
676+ override : override ,
677+ message : "source override names an unavailable gear; falling back to auto-detect" ,
678+ })
613679 } else {
680+ // Defensive Clone+DeleteFunc — the Alternatives slice
681+ // returned to callers must not share backing storage
682+ // with `ordered`, which a future change to this
683+ // function could mutate.
684+ alts := slices .Clone (ordered )
685+ alts = slices .DeleteFunc (alts , func (s string ) bool { return s == override })
614686 out [cat ] = SourceSelection {
615687 Category : cat ,
616688 Source : override ,
617- Reason : "operator override via " + overrideEnvVarFor ( cat ) ,
618- Alternatives : withoutString ( ordered , override ) ,
689+ Reason : "operator override via " + categoryEnvVar [ cat ] ,
690+ Alternatives : alts ,
619691 }
620692 continue
621693 }
622694 }
623695
624- // Auto-detect from preference order.
696+ // Auto-detect from preference order. Clone the alternatives
697+ // slice for the same defensive reason as the override branch.
625698 out [cat ] = SourceSelection {
626699 Category : cat ,
627700 Source : ordered [0 ],
628701 Reason : "auto-detected from preference order" ,
629- Alternatives : ordered [1 :],
702+ Alternatives : slices . Clone ( ordered [1 :]) ,
630703 }
631704 }
632705
633- return out
706+ return out , failures
634707}
635708
636- // deps_override fetches the operator's override for a category from
637- // Dependencies. Standalone (not a method) so tests that build
709+ // overrideForCategory fetches the operator's override for a category
710+ // from Dependencies. Standalone (not a method) so tests that build
638711// Dependencies directly stay readable.
639- func deps_override (d Dependencies , cat MetricCategory ) string {
712+ func overrideForCategory (d Dependencies , cat MetricCategory ) string {
640713 if d .SourceOverrides == nil {
641714 return ""
642715 }
643716 return d .SourceOverrides [cat ]
644717}
645718
646- // overrideEnvVarFor names the env var that controls a category. Kept
647- // in one place so warning messages and docs stay aligned with what
648- // operators actually set in their env files.
649- func overrideEnvVarFor (cat MetricCategory ) string {
650- switch cat {
651- case CategoryHTTPRequests :
652- return "GEARBOX_AGENT_HTTP_SOURCE"
653- default :
654- return "(unknown category)"
655- }
656- }
657-
658719// orderByPreference returns the subset of `available` ordered by where
659720// each entry appears in `preferred`. Entries in `available` that
660721// aren't in `preferred` get appended at the end (sorted alphabetically
@@ -688,25 +749,6 @@ func orderByPreference(available, preferred []string) []string {
688749 return out
689750}
690751
691- func containsString (haystack []string , needle string ) bool {
692- for _ , s := range haystack {
693- if s == needle {
694- return true
695- }
696- }
697- return false
698- }
699-
700- func withoutString (in []string , drop string ) []string {
701- out := make ([]string , 0 , len (in ))
702- for _ , s := range in {
703- if s != drop {
704- out = append (out , s )
705- }
706- }
707- return out
708- }
709-
710752// RegisterSystemRoutes registers cross-cutting agent endpoints that aren't
711753// tied to a single gear. Today that's just the capability table; future
712754// system-wide endpoints belong here too. Mount under the same auth group as
0 commit comments