Skip to content

Commit c438fc2

Browse files
author
Antonio Nesic
authored
Add periodic checks for Prometheus + add telemetry (#378)
* Add periodic checks for Prometheus + add telemetry * fix tests with fixtures and t.Helper() * Fix edge cases * Additional fixes
1 parent e9aee40 commit c438fc2

5 files changed

Lines changed: 362 additions & 8 deletions

File tree

internal/controller/collectionpolicy_controller.go

Lines changed: 132 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,15 @@ type CollectionPolicyReconciler struct {
8787
// OOM reconciler for periodic sweep of missed OOM events
8888
oomReconciler *collector.OOMReconciler
8989
oomReconcilerCancel context.CancelFunc
90+
91+
// Periodic Prometheus availability probe. Without this the prometheus
92+
// component health is set once at startup (via waitForPrometheusAvailability)
93+
// and never re-evaluated, so a transient outage at boot leaves the operator
94+
// permanently reporting prometheus as unhealthy even after recovery.
95+
prometheusProbeMu sync.Mutex
96+
prometheusProbeURL string
97+
prometheusProbeCancel context.CancelFunc
98+
prometheusProbeDone chan struct{} // closed by the goroutine on exit; nil until first start
9099
}
91100

92101
// pendingCollector represents a collector that was unavailable at registration time
@@ -1059,6 +1068,7 @@ func (r *CollectionPolicyReconciler) restartCollectors(
10591068
)
10601069

10611070
prometheusAvailable := r.waitForPrometheusAvailability(ctx, newConfig.PrometheusURL)
1071+
r.startPeriodicPrometheusHealthCheck(newConfig.PrometheusURL)
10621072
if !prometheusAvailable {
10631073
logger.Info(
10641074
"Prometheus is not available after waiting, will continue with restart but metrics may be limited",
@@ -1887,6 +1897,10 @@ func (r *CollectionPolicyReconciler) initializeCollectors(
18871897
)
18881898
logger.Info("Prometheus is available, continuing with full metrics collection")
18891899
}
1900+
1901+
// Start (or refresh) the periodic probe so health recovers from transient
1902+
// outages rather than being frozen on the one-shot startup result.
1903+
r.startPeriodicPrometheusHealthCheck(config.PrometheusURL)
18901904
} else {
18911905
r.TelemetryLogger.Report(
18921906
gen.LogLevel_LOG_LEVEL_WARN,
@@ -1898,6 +1912,8 @@ func (r *CollectionPolicyReconciler) initializeCollectors(
18981912
"zxporter_version": version.Get().String(),
18991913
},
19001914
)
1915+
// No URL — make sure any prior probe is stopped.
1916+
r.startPeriodicPrometheusHealthCheck("")
19011917
}
19021918

19031919
// Setup collection manager and basic services
@@ -4022,11 +4038,7 @@ func (r *CollectionPolicyReconciler) waitForPrometheusAvailability(
40224038
Transport: tr,
40234039
}
40244040

4025-
// Endpoint to verify prometheus is ready
4026-
healthEndpoint := fmt.Sprintf("%s/-/ready", prometheusURL)
4027-
if !strings.HasPrefix(prometheusURL, "http") {
4028-
healthEndpoint = fmt.Sprintf("http://%s/-/ready", prometheusURL)
4029-
}
4041+
healthEndpoint := prometheusHealthEndpoint(prometheusURL)
40304042

40314043
for i := 0; i < maxRetries; i++ {
40324044
select {
@@ -4162,3 +4174,118 @@ func (r *CollectionPolicyReconciler) updateHealthStatus(
41624174
r.HealthManager.UpdateStatus(health.ComponentPrometheus, status, message, metadata)
41634175
}
41644176
}
4177+
4178+
// startPeriodicPrometheusHealthCheck launches (or restarts) a goroutine that
4179+
// pings Prometheus every minute and updates ComponentPrometheus health. Without
4180+
// this, the one-shot startup check leaves the status frozen until something
4181+
// else happens to refresh it. Pass "" to stop the probe.
4182+
//
4183+
// The goroutine is parented on context.Background() rather than the caller's
4184+
// reconcile context: controller-runtime cancels the reconcile ctx as soon as
4185+
// Reconcile returns, which would kill the probe immediately. The stored cancel
4186+
// func is the only intended way to stop it (matches the OOM reconciler pattern).
4187+
//
4188+
// Idempotency: if a probe for the same URL is already running we no-op. We
4189+
// detect a dead goroutine (panic, lost cancel, etc.) via the done channel —
4190+
// if it's already closed we treat the probe as gone and start a fresh one.
4191+
func (r *CollectionPolicyReconciler) startPeriodicPrometheusHealthCheck(url string) {
4192+
r.prometheusProbeMu.Lock()
4193+
defer r.prometheusProbeMu.Unlock()
4194+
4195+
if r.prometheusProbeURL == url && r.probeStillAliveLocked() {
4196+
return
4197+
}
4198+
if r.prometheusProbeCancel != nil {
4199+
r.prometheusProbeCancel()
4200+
}
4201+
r.prometheusProbeURL = url
4202+
r.prometheusProbeCancel = nil
4203+
r.prometheusProbeDone = nil
4204+
if url == "" {
4205+
return
4206+
}
4207+
4208+
endpoint := prometheusHealthEndpoint(url)
4209+
ctx, cancel := context.WithCancel(context.Background())
4210+
done := make(chan struct{})
4211+
r.prometheusProbeCancel = cancel
4212+
r.prometheusProbeDone = done
4213+
4214+
go r.runPrometheusProbe(ctx, done, url, endpoint)
4215+
}
4216+
4217+
// prometheusHealthEndpoint returns the full /-/ready URL, defaulting to http://
4218+
// when the caller passed a bare host:port. Shared by waitForPrometheusAvailability
4219+
// and the periodic probe so both build the same endpoint from the same input.
4220+
func prometheusHealthEndpoint(url string) string {
4221+
if !strings.HasPrefix(url, "http") {
4222+
return "http://" + url + "/-/ready"
4223+
}
4224+
return url + "/-/ready"
4225+
}
4226+
4227+
// probeStillAliveLocked returns true if the existing probe goroutine is still
4228+
// running. Caller must hold prometheusProbeMu.
4229+
func (r *CollectionPolicyReconciler) probeStillAliveLocked() bool {
4230+
if r.prometheusProbeCancel == nil || r.prometheusProbeDone == nil {
4231+
return false
4232+
}
4233+
select {
4234+
case <-r.prometheusProbeDone:
4235+
return false
4236+
default:
4237+
return true
4238+
}
4239+
}
4240+
4241+
// runPrometheusProbe is the probe goroutine body. Defers a recover so a panic
4242+
// in the HTTP client does not silently leave the probe dead with no cleanup,
4243+
// and always closes done so probeStillAliveLocked can detect exit.
4244+
func (r *CollectionPolicyReconciler) runPrometheusProbe(ctx context.Context, done chan struct{}, url, endpoint string) {
4245+
defer close(done)
4246+
defer func() {
4247+
if rec := recover(); rec != nil {
4248+
r.Log.Error(fmt.Errorf("%v", rec), "Prometheus probe goroutine panicked", "url", url)
4249+
}
4250+
}()
4251+
4252+
// Clone DefaultTransport so the probe inherits proxy/TLS settings (matches
4253+
// waitForPrometheusAvailability). Re-using DefaultTransport directly would
4254+
// share the connection pool with every other consumer in the binary.
4255+
tr := http.DefaultTransport.(*http.Transport).Clone()
4256+
client := &http.Client{Timeout: 5 * time.Second, Transport: tr}
4257+
ticker := time.NewTicker(60 * time.Second)
4258+
defer ticker.Stop()
4259+
4260+
for {
4261+
select {
4262+
case <-ctx.Done():
4263+
return
4264+
case <-ticker.C:
4265+
status, msg := health.HealthStatusHealthy, "Prometheus available"
4266+
meta := map[string]string{"url": url}
4267+
// Build the request from ctx so a URL change or shutdown cancels
4268+
// any in-flight call immediately rather than waiting for the timeout.
4269+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
4270+
if err != nil {
4271+
status, msg = health.HealthStatusDegraded, "Prometheus probe request build failed"
4272+
meta["error"] = err.Error()
4273+
r.updateHealthStatus(status, msg, meta)
4274+
continue
4275+
}
4276+
resp, err := client.Do(req)
4277+
switch {
4278+
case err != nil:
4279+
status, msg = health.HealthStatusDegraded, "Prometheus probe failed"
4280+
meta["error"] = err.Error()
4281+
case resp.StatusCode != http.StatusOK:
4282+
status, msg = health.HealthStatusDegraded, "Prometheus probe returned non-OK status"
4283+
meta["status_code"] = fmt.Sprintf("%d", resp.StatusCode)
4284+
}
4285+
if resp != nil {
4286+
_ = resp.Body.Close()
4287+
}
4288+
r.updateHealthStatus(status, msg, meta)
4289+
}
4290+
}
4291+
}

internal/controller/custom.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -447,10 +447,48 @@ func (c *EnvBasedController) initializeTelemetryComponents(ctx context.Context)
447447
c.Reconciler.Sender = sender
448448
c.Reconciler.TelemetryLogger = telemetryLogger
449449

450+
if c.Reconciler.HealthManager != nil {
451+
c.Reconciler.HealthManager.SetTransitionObserver(
452+
newHealthTransitionObserver(telemetryLogger),
453+
)
454+
}
455+
450456
c.Log.Info("Successfully initialized telemetry components")
451457
return nil
452458
}
453459

460+
// newHealthTransitionObserver returns a TransitionObserver that emits a telemetry
461+
// log on every component status change so we can trace flips in Datadog rather
462+
// than only seeing the latest snapshot via the heartbeat.
463+
//
464+
// The dispatch is offloaded to a goroutine so observer execution never blocks
465+
// the caller of UpdateStatus. tl.Report is currently non-blocking (it queues
466+
// with a select-default drop), but we should not couple the observer contract
467+
// to that internal detail — a future telemetry implementation that does I/O
468+
// would otherwise stall every health transition.
469+
func newHealthTransitionObserver(tl telemetry_logger.Logger) health.TransitionObserver {
470+
return func(component string, oldStatus, newStatus health.HealthStatus, message string, metadata map[string]string) {
471+
level := gen.LogLevel_LOG_LEVEL_INFO
472+
switch newStatus {
473+
case health.HealthStatusDegraded:
474+
level = gen.LogLevel_LOG_LEVEL_WARN
475+
case health.HealthStatusUnhealthy:
476+
level = gen.LogLevel_LOG_LEVEL_ERROR
477+
}
478+
479+
fields := make(map[string]string, len(metadata)+4)
480+
for k, v := range metadata {
481+
fields[k] = v
482+
}
483+
fields["component"] = component
484+
fields["old_status"] = oldStatus.String()
485+
fields["new_status"] = newStatus.String()
486+
fields["zxporter_version"] = version.Get().String()
487+
488+
go tl.Report(level, "HealthManager_StatusTransition", message, nil, fields)
489+
}
490+
}
491+
454492
// doReconcile performs a single reconciliation
455493
func (c *EnvBasedController) doReconcile(ctx context.Context) error {
456494
// c.Log.Info("Performing reconciliation based on environment variables")

internal/health/manager.go

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,20 @@ type ComponentStatus struct {
2424
Metadata map[string]string
2525
}
2626

27+
// TransitionObserver is invoked whenever a component's status changes via UpdateStatus.
28+
// It is called outside the HealthManager lock so observers may safely call back into
29+
// the manager (e.g. to read other component statuses) without deadlocking. The observer
30+
// is invoked synchronously, but the typical implementation should hand off to a queue
31+
// so the UpdateStatus call site stays fast.
32+
type TransitionObserver func(component string, oldStatus, newStatus HealthStatus, message string, metadata map[string]string)
33+
2734
type HealthManager struct {
2835
mu sync.RWMutex
2936
components map[string]*ComponentStatus
3037
livenessGraceUntil time.Time // LivenessCheck always passes before this deadline
3138
readinessGraceUntil time.Time // ReadinessCheck always passes before this deadline
3239
standby bool // standby=true when not leader; readiness passes unconditionally
40+
transitionObserver TransitionObserver
3341
}
3442

3543
// NewHealthManager creates a new HealthManager
@@ -59,16 +67,34 @@ func (hm *HealthManager) Deregister(name string) {
5967
delete(hm.components, name)
6068
}
6169

62-
// UpdateStatus updates the health status, message, and metadata for a component
70+
// SetTransitionObserver registers (or clears, if nil) a callback invoked on every
71+
// component status transition. Only one observer is held at a time. The observer
72+
// runs outside the lock so it may safely re-enter the HealthManager.
73+
func (hm *HealthManager) SetTransitionObserver(obs TransitionObserver) {
74+
hm.mu.Lock()
75+
defer hm.mu.Unlock()
76+
hm.transitionObserver = obs
77+
}
78+
79+
// UpdateStatus updates the health status, message, and metadata for a component.
80+
// If the new status differs from the previous one, the registered TransitionObserver
81+
// (if any) is invoked outside the lock with old and new status.
6382
func (hm *HealthManager) UpdateStatus(
6483
name string,
6584
status HealthStatus,
6685
message string,
6786
metadata map[string]string,
6887
) {
88+
var (
89+
transitioned bool
90+
observer TransitionObserver
91+
oldStatus HealthStatus
92+
metaCopy map[string]string
93+
)
94+
6995
hm.mu.Lock()
70-
defer hm.mu.Unlock()
7196
if comp, exists := hm.components[name]; exists {
97+
oldStatus = comp.Status
7298
comp.Status = status
7399
comp.Message = message
74100
if metadata != nil {
@@ -78,6 +104,21 @@ func (hm *HealthManager) UpdateStatus(
78104
}
79105
comp.Metadata = m
80106
}
107+
if oldStatus != status {
108+
transitioned = true
109+
observer = hm.transitionObserver
110+
if observer != nil {
111+
metaCopy = make(map[string]string, len(comp.Metadata))
112+
for k, v := range comp.Metadata {
113+
metaCopy[k] = v
114+
}
115+
}
116+
}
117+
}
118+
hm.mu.Unlock()
119+
120+
if transitioned && observer != nil {
121+
observer(name, oldStatus, status, message, metaCopy)
81122
}
82123
}
83124

0 commit comments

Comments
 (0)