mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-27 05:57:27 +00:00
c22871c0bd
Several Off toggles on the Thresholds page did nothing or did the opposite, because each layer read 0, -1 and "missing" differently: - PMG and Docker restart/memory-limit defaults: the toggle sends -1 and NormalizePMGDefaults/NormalizeDockerDefaults turned anything <= 0 back into the factory value, so Off reverted on save. The Docker restart-loop and memory-limit checks also had no Off at all. - Backup and snapshot critical age: the editor sanitizers and the save payload raised a 0 critical to the warning age, so turning critical off made backups go critical at the warning age. - A Docker container override saved for CPU alone carried DisableConnectivity=false and re-enabled that container's state alerts over a disabled Docker default. - Per-server PMG cells, PMG bulk edit and per-container restart and memory-limit cells wrote fields ThresholdConfig does not have, so the backend dropped them on save. 0 is now Off for these counts; negative values (the toggle's -1) are stored as 0. Keys an older alerts.json or an import omits decode as ThresholdCountUnset and take the factory value, so existing installs keep their alerts; persistence resolves the marker before writing. A PMG quarantine growth level with one half Off turns that level off instead of invalidating the spec and skipping the whole check. Docker container overrides re-enable state alerts only with their own severity, as applyThresholdOverride does elsewhere. The frontend keeps a 0 critical age Off, the group header says "Restart loop off", and rows only offer metrics an override can store. AI Patrol's threshold adapter still maps an Off alert threshold to its factory value; that is a Patrol product decision and is left as is.
1069 lines
35 KiB
Go
1069 lines
35 KiB
Go
package alerts
|
|
|
|
import (
|
|
"fmt"
|
|
"math"
|
|
"strings"
|
|
"time"
|
|
|
|
alertspecs "github.com/rcourtman/pulse-go-rewrite/internal/alerts/specs"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/models"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
|
|
"github.com/rs/zerolog/log"
|
|
)
|
|
|
|
// pmgQuarantineSnapshot stores quarantine counts at a point in time for growth detection
|
|
type pmgQuarantineSnapshot struct {
|
|
Spam int
|
|
Virus int
|
|
Timestamp time.Time
|
|
}
|
|
|
|
// pmgMailMetricSample stores a single hourly mail count sample
|
|
type pmgMailMetricSample struct {
|
|
SpamIn float64
|
|
SpamOut float64
|
|
VirusIn float64
|
|
VirusOut float64
|
|
Timestamp time.Time
|
|
}
|
|
|
|
// pmgAnomalyTracker tracks history for anomaly detection.
|
|
type pmgAnomalyTracker struct {
|
|
Samples []pmgMailMetricSample // Ring buffer (max 48 samples)
|
|
LastSampleTime time.Time // Timestamp of most recent sample
|
|
SampleCount int // Total samples collected (for warmup check)
|
|
}
|
|
|
|
func isPMGOffline(pmg models.PMGInstance) bool {
|
|
status := strings.ToLower(strings.TrimSpace(pmg.Status))
|
|
health := strings.ToLower(strings.TrimSpace(pmg.ConnectionHealth))
|
|
return status == "offline" || health == "error" || health == "failed" || health == "unhealthy"
|
|
}
|
|
|
|
func (m *Manager) clearPMGMetricAlerts(pmgID string) {
|
|
pmgID = strings.TrimSpace(pmgID)
|
|
if pmgID == "" {
|
|
return
|
|
}
|
|
|
|
offlineAlertID := canonicalConnectivityStateID(pmgID)
|
|
|
|
m.mu.RLock()
|
|
alertIDs := make([]string, 0)
|
|
for storageKey, alert := range m.activeAlerts {
|
|
alertID := storageKey
|
|
if alertID == offlineAlertID {
|
|
continue
|
|
}
|
|
if alert != nil && alert.ResourceID == pmgID {
|
|
alertIDs = append(alertIDs, alertID)
|
|
}
|
|
}
|
|
m.mu.RUnlock()
|
|
|
|
for _, alertID := range alertIDs {
|
|
m.clearAlert(alertID)
|
|
}
|
|
}
|
|
|
|
// CheckPMG checks a Proxmox Mail Gateway instance against thresholds
|
|
func (m *Manager) CheckPMG(pmg models.PMGInstance) {
|
|
m.mu.RLock()
|
|
if !m.config.Enabled {
|
|
m.mu.RUnlock()
|
|
return
|
|
}
|
|
if allDisabled, _ := m.alertPolicyTypeSwitchesNoLock("pmg"); allDisabled {
|
|
m.mu.RUnlock()
|
|
// Clear any existing PMG alerts when all PMG alerts are disabled.
|
|
m.mu.Lock()
|
|
m.mu.Unlock()
|
|
m.clearPMGMetricAlerts(pmg.ID)
|
|
m.clearAlert(canonicalConnectivityStateID(pmg.ID))
|
|
return
|
|
}
|
|
|
|
// Check if there's an override for this PMG instance
|
|
override, hasOverride := m.config.Overrides[pmg.ID]
|
|
_, disablePMGOffline := m.alertPolicyTypeSwitchesNoLock("pmg")
|
|
pmgDefaults := m.config.PMGDefaults
|
|
m.mu.RUnlock()
|
|
|
|
// Check override disable BEFORE offline detection to prevent spurious notifications
|
|
if hasOverride && override.Disabled {
|
|
m.mu.Lock()
|
|
m.mu.Unlock()
|
|
m.clearPMGMetricAlerts(pmg.ID)
|
|
m.clearAlert(canonicalConnectivityStateID(pmg.ID))
|
|
return
|
|
}
|
|
|
|
pmgOffline := isPMGOffline(pmg)
|
|
|
|
// Handle offline detection
|
|
if disablePMGOffline {
|
|
// Clear tracking and any existing offline alerts when globally disabled
|
|
m.mu.Lock()
|
|
m.mu.Unlock()
|
|
m.clearAlert(canonicalConnectivityStateID(pmg.ID))
|
|
} else {
|
|
// Check if PMG is offline (similar to PBS/nodes)
|
|
if pmgOffline {
|
|
m.checkPMGOffline(pmg)
|
|
} else {
|
|
// Clear any existing offline alert if PMG is back online
|
|
m.clearPMGOfflineAlert(pmg)
|
|
}
|
|
}
|
|
|
|
// When PMG is offline/unhealthy, clear stale metric alerts immediately.
|
|
if pmgOffline {
|
|
m.clearPMGMetricAlerts(pmg.ID)
|
|
return
|
|
}
|
|
|
|
// Check queue depths across all nodes
|
|
m.checkPMGQueueDepths(pmg, pmgDefaults)
|
|
// Check oldest message age across all nodes
|
|
m.checkPMGOldestMessage(pmg, pmgDefaults)
|
|
// Check quarantine backlog and growth
|
|
m.checkPMGQuarantineBacklog(pmg, pmgDefaults)
|
|
// Check spam/virus rate anomalies
|
|
m.checkPMGAnomalies(pmg, pmgDefaults)
|
|
// Check per-node queue health
|
|
m.checkPMGNodeQueues(pmg, pmgDefaults)
|
|
}
|
|
|
|
// checkPMGOffline creates an alert for offline PMG instances
|
|
func (m *Manager) checkPMGOffline(pmg models.PMGInstance) {
|
|
m.mu.Lock()
|
|
m.mu.Unlock()
|
|
|
|
m.mu.RLock()
|
|
override, hasOverride := m.config.Overrides[pmg.ID]
|
|
m.mu.RUnlock()
|
|
disabled := hasOverride && (override.Disabled || override.DisableConnectivity)
|
|
spec, err := buildCanonicalConnectivitySpec(pmg.ID, pmg.Name, unifiedresources.ResourceTypePMG, AlertLevelCritical, 3, disabled)
|
|
if err != nil {
|
|
log.Warn().
|
|
Err(err).
|
|
Str("pmg", pmg.Name).
|
|
Str("pmgID", pmg.ID).
|
|
Msg("Skipping invalid canonical PMG connectivity spec")
|
|
return
|
|
}
|
|
|
|
_, _ = m.evaluateCanonicalLifecycleAlert(canonicalLifecycleAlertParams{
|
|
Spec: spec,
|
|
Evidence: alertspecs.AlertEvidence{ObservedAt: time.Now(), Connectivity: &alertspecs.ConnectivityEvidence{Signal: "status", Connected: false}},
|
|
AlertID: fmt.Sprintf("pmg-offline-%s", pmg.ID),
|
|
AlertType: "offline",
|
|
ResourceID: pmg.ID,
|
|
ResourceName: pmg.Name,
|
|
Node: pmg.Host,
|
|
Instance: pmg.Name,
|
|
Message: fmt.Sprintf("PMG instance %s is offline", pmg.Name),
|
|
Metadata: map[string]interface{}{
|
|
"resourceType": "pmg",
|
|
"status": pmg.Status,
|
|
"connectionHealth": pmg.ConnectionHealth,
|
|
},
|
|
RateLimit: true,
|
|
DispatchAsync: true,
|
|
})
|
|
}
|
|
|
|
// clearPMGOfflineAlert removes offline alert when PMG comes back online
|
|
func (m *Manager) clearPMGOfflineAlert(pmg models.PMGInstance) {
|
|
m.clearResourceOfflineAlert(pmg.ID, pmg.Name, pmg.Host, "PMG", offlineRecoveryConfirmationsDefault)
|
|
}
|
|
|
|
// pmgQueuesComplete requires observations from every discovered node before
|
|
// evaluating aggregate queue health. Missing/disabled collection is not zero.
|
|
func pmgQueuesComplete(pmg models.PMGInstance) bool {
|
|
if len(pmg.Nodes) == 0 {
|
|
return false
|
|
}
|
|
for _, node := range pmg.Nodes {
|
|
if node.QueueStatus == nil {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// A partial non-negative queue reading is a lower bound. It can establish the
|
|
// highest configured severity, but never recovery or a lower severity that
|
|
// could downgrade an alert supported by the missing node.
|
|
func pmgQueueMetricEvaluable(pmg models.PMGInstance, value, warning, critical int) bool {
|
|
if pmgQueuesComplete(pmg) {
|
|
return true
|
|
}
|
|
if critical > 0 {
|
|
return value >= critical
|
|
}
|
|
return warning > 0 && value >= warning
|
|
}
|
|
|
|
// checkPMGQueueDepths checks PMG mail queue depths and creates alerts
|
|
// Evaluates all queue types (total, deferred, hold) independently
|
|
func (m *Manager) checkPMGQueueDepths(pmg models.PMGInstance, defaults PMGThresholdConfig) {
|
|
// Aggregate queue totals across all nodes
|
|
var totalQueue, totalDeferred, totalHold int
|
|
|
|
for _, node := range pmg.Nodes {
|
|
if node.QueueStatus != nil {
|
|
totalQueue += node.QueueStatus.Total
|
|
totalDeferred += node.QueueStatus.Deferred
|
|
totalHold += node.QueueStatus.Hold
|
|
}
|
|
}
|
|
|
|
if pmgQueueMetricEvaluable(pmg, totalQueue, defaults.QueueTotalWarning, defaults.QueueTotalCritical) {
|
|
m.checkPMGQueueDepth(pmg, defaults.QueueTotalWarning, defaults.QueueTotalCritical, totalQueue, "queue-total", "queue-depth",
|
|
"PMG %s has %d total messages in queue (threshold: %d)", "total_queue")
|
|
}
|
|
if pmgQueueMetricEvaluable(pmg, totalDeferred, defaults.DeferredQueueWarn, defaults.DeferredQueueCritical) {
|
|
m.checkPMGQueueDepth(pmg, defaults.DeferredQueueWarn, defaults.DeferredQueueCritical, totalDeferred, "queue-deferred", "queue-deferred",
|
|
"PMG %s has %d deferred messages (threshold: %d)", "deferred_queue")
|
|
}
|
|
if pmgQueueMetricEvaluable(pmg, totalHold, defaults.HoldQueueWarn, defaults.HoldQueueCritical) {
|
|
m.checkPMGQueueDepth(pmg, defaults.HoldQueueWarn, defaults.HoldQueueCritical, totalHold, "queue-hold", "queue-hold",
|
|
"PMG %s has %d held messages (threshold: %d)", "hold_queue")
|
|
}
|
|
}
|
|
|
|
func thresholdForCanonicalSeverity(severity alertspecs.AlertSeverity, warningThreshold, criticalThreshold float64) float64 {
|
|
switch severity {
|
|
case alertspecs.AlertSeverityCritical:
|
|
if criticalThreshold > 0 {
|
|
return criticalThreshold
|
|
}
|
|
return warningThreshold
|
|
case alertspecs.AlertSeverityWarning:
|
|
if warningThreshold > 0 {
|
|
return warningThreshold
|
|
}
|
|
return criticalThreshold
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
func quarantineAlertThreshold(metricType, reason string, previousCount int, defaults PMGThresholdConfig) float64 {
|
|
switch metricType {
|
|
case "spam":
|
|
switch reason {
|
|
case "change-threshold-current-critical":
|
|
return float64(defaults.QuarantineSpamCritical)
|
|
case "change-threshold-current-warning":
|
|
return float64(defaults.QuarantineSpamWarn)
|
|
case "change-threshold-growth-critical":
|
|
return float64(previousCount + defaults.QuarantineGrowthCritMin)
|
|
case "change-threshold-growth-warning":
|
|
return float64(previousCount + defaults.QuarantineGrowthWarnMin)
|
|
default:
|
|
return 0
|
|
}
|
|
case "virus":
|
|
switch reason {
|
|
case "change-threshold-current-critical":
|
|
return float64(defaults.QuarantineVirusCritical)
|
|
case "change-threshold-current-warning":
|
|
return float64(defaults.QuarantineVirusWarn)
|
|
case "change-threshold-growth-critical":
|
|
return float64(previousCount + defaults.QuarantineGrowthCritMin)
|
|
case "change-threshold-growth-warning":
|
|
return float64(previousCount + defaults.QuarantineGrowthWarnMin)
|
|
default:
|
|
return 0
|
|
}
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
func quarantineAlertMessage(pmg models.PMGInstance, metricType string, current, previousCount int, reason string, defaults PMGThresholdConfig) string {
|
|
switch reason {
|
|
case "change-threshold-growth-critical", "change-threshold-growth-warning":
|
|
growth := current - previousCount
|
|
growthPct := 0.0
|
|
if previousCount > 0 {
|
|
growthPct = (float64(growth) / float64(previousCount)) * 100
|
|
}
|
|
if reason == "change-threshold-growth-critical" {
|
|
return fmt.Sprintf("PMG %s %s quarantine growing rapidly: +%d messages (+%.1f%%) in 2 hours", pmg.Name, metricType, growth, growthPct)
|
|
}
|
|
return fmt.Sprintf("PMG %s %s quarantine growing: +%d messages (+%.1f%%) in 2 hours", pmg.Name, metricType, growth, growthPct)
|
|
default:
|
|
threshold := quarantineAlertThreshold(metricType, reason, previousCount, defaults)
|
|
return fmt.Sprintf("PMG %s has %d %s messages in quarantine (threshold: %d)", pmg.Name, current, metricType, int(threshold))
|
|
}
|
|
}
|
|
|
|
func (m *Manager) checkPMGQueueDepth(pmg models.PMGInstance, warningThreshold, criticalThreshold, value int, alertIDSuffix, alertType, messageFormat, logField string) {
|
|
if warningThreshold <= 0 && criticalThreshold <= 0 {
|
|
return
|
|
}
|
|
|
|
alertID := fmt.Sprintf("%s-%s", pmg.ID, alertIDSuffix)
|
|
spec, err := buildCanonicalSeverityThresholdSpec(alertID, pmg.ID, pmg.Name, unifiedresources.ResourceTypePMG, alertType, float64(warningThreshold), float64(criticalThreshold), false)
|
|
if err != nil {
|
|
log.Warn().
|
|
Err(err).
|
|
Str("pmg", pmg.Name).
|
|
Str("alertID", alertID).
|
|
Msg("Skipping invalid canonical PMG queue spec")
|
|
return
|
|
}
|
|
|
|
result, _ := m.evaluateCanonicalStatefulAlert(canonicalStatefulAlertParams{
|
|
Spec: spec,
|
|
Evidence: alertspecs.AlertEvidence{
|
|
ObservedAt: time.Now(),
|
|
SeverityThreshold: &alertspecs.SeverityThresholdEvidence{
|
|
Metric: alertType,
|
|
Direction: alertspecs.ThresholdDirectionAbove,
|
|
Observed: float64(value),
|
|
},
|
|
},
|
|
AlertID: alertID,
|
|
AlertType: alertType,
|
|
ResourceID: pmg.ID,
|
|
ResourceName: pmg.Name,
|
|
Node: pmg.Host,
|
|
Instance: pmg.Name,
|
|
MessageBuilder: func(result alertspecs.EvaluationResult) (string, float64, float64) {
|
|
threshold := thresholdForCanonicalSeverity(result.State.Severity, float64(warningThreshold), float64(criticalThreshold))
|
|
return fmt.Sprintf(messageFormat, pmg.Name, value, int(threshold)), float64(value), threshold
|
|
},
|
|
DispatchAsync: true,
|
|
})
|
|
|
|
if result.Transition != nil && result.Transition.Kind == alertspecs.EvaluationTransitionActivated {
|
|
level, ok := alertLevelFromCanonicalSeverity(result.State.Severity)
|
|
if !ok {
|
|
level = AlertLevelWarning
|
|
}
|
|
log.Warn().
|
|
Str("pmg", pmg.Name).
|
|
Int(logField, value).
|
|
Int("threshold", int(thresholdForCanonicalSeverity(result.State.Severity, float64(warningThreshold), float64(criticalThreshold)))).
|
|
Str("level", string(level)).
|
|
Msg(fmt.Sprintf("PMG %s alert triggered", alertType))
|
|
}
|
|
}
|
|
|
|
// checkPMGOldestMessage checks oldest queued message age and creates alerts
|
|
func (m *Manager) checkPMGOldestMessage(pmg models.PMGInstance, defaults PMGThresholdConfig) {
|
|
if defaults.OldestMessageWarnMins <= 0 && defaults.OldestMessageCritMins <= 0 {
|
|
return
|
|
}
|
|
|
|
// Find the oldest message age across all nodes
|
|
var oldestAge int64 // in seconds
|
|
for _, node := range pmg.Nodes {
|
|
if node.QueueStatus != nil && node.QueueStatus.OldestAge > oldestAge {
|
|
oldestAge = node.QueueStatus.OldestAge
|
|
}
|
|
}
|
|
|
|
if !pmgQueueMetricEvaluable(pmg, int(oldestAge/60), defaults.OldestMessageWarnMins, defaults.OldestMessageCritMins) {
|
|
return
|
|
}
|
|
|
|
if oldestAge == 0 {
|
|
// No messages in queue, clear any existing alert
|
|
alertID := fmt.Sprintf("%s-oldest-message", pmg.ID)
|
|
m.clearAlert(buildCanonicalStateID(pmg.ID, alertID))
|
|
return
|
|
}
|
|
|
|
alertID := fmt.Sprintf("%s-oldest-message", pmg.ID)
|
|
oldestMinutes := oldestAge / 60
|
|
spec, err := buildCanonicalSeverityThresholdSpec(alertID, pmg.ID, pmg.Name, unifiedresources.ResourceTypePMG, "message-age", float64(defaults.OldestMessageWarnMins), float64(defaults.OldestMessageCritMins), false)
|
|
if err != nil {
|
|
log.Warn().
|
|
Err(err).
|
|
Str("pmg", pmg.Name).
|
|
Str("alertID", alertID).
|
|
Msg("Skipping invalid canonical PMG oldest-message spec")
|
|
return
|
|
}
|
|
|
|
result, _ := m.evaluateCanonicalStatefulAlert(canonicalStatefulAlertParams{
|
|
Spec: spec,
|
|
Evidence: alertspecs.AlertEvidence{
|
|
ObservedAt: time.Now(),
|
|
SeverityThreshold: &alertspecs.SeverityThresholdEvidence{
|
|
Metric: "message-age",
|
|
Direction: alertspecs.ThresholdDirectionAbove,
|
|
Observed: float64(oldestMinutes),
|
|
},
|
|
},
|
|
AlertID: alertID,
|
|
AlertType: "message-age",
|
|
ResourceID: pmg.ID,
|
|
ResourceName: pmg.Name,
|
|
Node: pmg.Host,
|
|
Instance: pmg.Name,
|
|
MessageBuilder: func(result alertspecs.EvaluationResult) (string, float64, float64) {
|
|
threshold := thresholdForCanonicalSeverity(result.State.Severity, float64(defaults.OldestMessageWarnMins), float64(defaults.OldestMessageCritMins))
|
|
return fmt.Sprintf("PMG %s has messages queued for %d minutes (threshold: %d minutes)", pmg.Name, oldestMinutes, int64(threshold)), float64(oldestMinutes), threshold
|
|
},
|
|
DispatchAsync: true,
|
|
})
|
|
|
|
if result.Transition != nil && result.Transition.Kind == alertspecs.EvaluationTransitionActivated {
|
|
level, ok := alertLevelFromCanonicalSeverity(result.State.Severity)
|
|
if !ok {
|
|
level = AlertLevelWarning
|
|
}
|
|
log.Warn().
|
|
Str("pmg", pmg.Name).
|
|
Int64("oldest_minutes", oldestMinutes).
|
|
Int64("threshold", int64(thresholdForCanonicalSeverity(result.State.Severity, float64(defaults.OldestMessageWarnMins), float64(defaults.OldestMessageCritMins)))).
|
|
Str("level", string(level)).
|
|
Msg("PMG oldest message age alert triggered")
|
|
}
|
|
}
|
|
|
|
// evaluatePMGNodeQueueAlert runs one per-node PMG queue threshold check
|
|
// (total / deferred / hold share this shape). messageNoun is the operator
|
|
// phrasing ("total messages in queue", "deferred messages", "held messages")
|
|
// and specLabel feeds the invalid-spec log line. It returns true when the
|
|
// caller must skip the node's remaining checks — either the below-threshold
|
|
// clear or an invalid spec — preserving the historical per-node
|
|
// short-circuit semantics.
|
|
func (m *Manager) evaluatePMGNodeQueueAlert(pmg models.PMGInstance, nodeName, metric, specLabel, messageNoun string, observed, scaledWarn, scaledCrit, median int) bool {
|
|
if scaledWarn <= 0 && scaledCrit <= 0 {
|
|
return false
|
|
}
|
|
|
|
alertID := fmt.Sprintf("%s-%s-%s", pmg.ID, nodeName, metric)
|
|
if (scaledCrit <= 0 || observed < scaledCrit) && (scaledWarn <= 0 || observed < scaledWarn) {
|
|
m.clearAlert(buildCanonicalStateID(pmg.ID, alertID))
|
|
return true
|
|
}
|
|
|
|
// Add outlier indicator to message if applicable
|
|
isOutlier := isQueueOutlier(observed, median)
|
|
outlierNote := ""
|
|
if isOutlier {
|
|
outlierNote = ", outlier"
|
|
}
|
|
|
|
spec, err := buildCanonicalSeverityThresholdSpec(alertID, pmg.ID, pmg.Name, unifiedresources.ResourceTypePMG, metric, float64(scaledWarn), float64(scaledCrit), false)
|
|
if err != nil {
|
|
log.Warn().Err(err).Str("pmg", pmg.Name).Str("alertID", alertID).Msg("Skipping invalid canonical PMG node " + specLabel + " spec")
|
|
return true
|
|
}
|
|
_, _ = m.evaluateCanonicalStatefulAlert(canonicalStatefulAlertParams{
|
|
Spec: spec,
|
|
Evidence: alertspecs.AlertEvidence{
|
|
ObservedAt: time.Now(),
|
|
SeverityThreshold: &alertspecs.SeverityThresholdEvidence{
|
|
Metric: metric,
|
|
Direction: alertspecs.ThresholdDirectionAbove,
|
|
Observed: float64(observed),
|
|
},
|
|
},
|
|
AlertID: alertID,
|
|
AlertType: metric,
|
|
ResourceID: pmg.ID,
|
|
ResourceName: pmg.Name,
|
|
Node: nodeName,
|
|
Instance: pmg.Name,
|
|
MessageBuilder: func(result alertspecs.EvaluationResult) (string, float64, float64) {
|
|
currentThreshold := thresholdForCanonicalSeverity(result.State.Severity, float64(scaledWarn), float64(scaledCrit))
|
|
return fmt.Sprintf("PMG node %s on %s has %d %s (threshold: %d%s)", nodeName, pmg.Name, observed, messageNoun, int(currentThreshold), outlierNote), float64(observed), currentThreshold
|
|
},
|
|
DispatchAsync: true,
|
|
})
|
|
return false
|
|
}
|
|
|
|
// checkPMGNodeQueues checks individual PMG node queue health
|
|
// Uses scaled thresholds (60% warn, 80% crit) and outlier detection
|
|
func (m *Manager) checkPMGNodeQueues(pmg models.PMGInstance, defaults PMGThresholdConfig) {
|
|
if len(pmg.Nodes) == 0 {
|
|
return
|
|
}
|
|
|
|
// Calculate median queue values across nodes for outlier detection
|
|
nodeQueueTotals := make([]int, 0, len(pmg.Nodes))
|
|
nodeQueueDeferred := make([]int, 0, len(pmg.Nodes))
|
|
nodeQueueHold := make([]int, 0, len(pmg.Nodes))
|
|
|
|
for _, node := range pmg.Nodes {
|
|
if node.QueueStatus != nil {
|
|
nodeQueueTotals = append(nodeQueueTotals, node.QueueStatus.Total)
|
|
nodeQueueDeferred = append(nodeQueueDeferred, node.QueueStatus.Deferred)
|
|
nodeQueueHold = append(nodeQueueHold, node.QueueStatus.Hold)
|
|
}
|
|
}
|
|
|
|
medianTotal := calculateMedianInt(nodeQueueTotals)
|
|
medianDeferred := calculateMedianInt(nodeQueueDeferred)
|
|
medianHold := calculateMedianInt(nodeQueueHold)
|
|
|
|
// Scaled thresholds: 60% for warning, 80% for critical (computed once, used for all nodes)
|
|
scaledQueueWarn := scaleThreshold(defaults.QueueTotalWarning, 0.6)
|
|
scaledQueueCrit := scaleThreshold(defaults.QueueTotalCritical, 0.8)
|
|
scaledDeferredWarn := scaleThreshold(defaults.DeferredQueueWarn, 0.6)
|
|
scaledDeferredCrit := scaleThreshold(defaults.DeferredQueueCritical, 0.8)
|
|
scaledHoldWarn := scaleThreshold(defaults.HoldQueueWarn, 0.6)
|
|
scaledHoldCrit := scaleThreshold(defaults.HoldQueueCritical, 0.8)
|
|
scaledAgeWarn := scaleThreshold(defaults.OldestMessageWarnMins, 0.6)
|
|
scaledAgeCrit := scaleThreshold(defaults.OldestMessageCritMins, 0.8)
|
|
|
|
// Check each node
|
|
for _, node := range pmg.Nodes {
|
|
if node.QueueStatus == nil {
|
|
continue
|
|
}
|
|
|
|
// Check total queue - always check thresholds
|
|
if m.evaluatePMGNodeQueueAlert(pmg, node.Name, "queue-total", "queue", "total messages in queue", node.QueueStatus.Total, scaledQueueWarn, scaledQueueCrit, medianTotal) {
|
|
continue
|
|
}
|
|
|
|
// Check deferred queue - always check thresholds
|
|
if m.evaluatePMGNodeQueueAlert(pmg, node.Name, "queue-deferred", "deferred queue", "deferred messages", node.QueueStatus.Deferred, scaledDeferredWarn, scaledDeferredCrit, medianDeferred) {
|
|
continue
|
|
}
|
|
|
|
// Check hold queue - always check thresholds
|
|
if m.evaluatePMGNodeQueueAlert(pmg, node.Name, "queue-hold", "hold queue", "held messages", node.QueueStatus.Hold, scaledHoldWarn, scaledHoldCrit, medianHold) {
|
|
continue
|
|
}
|
|
|
|
// Check oldest message age per node
|
|
if scaledAgeWarn > 0 || scaledAgeCrit > 0 {
|
|
oldestAge := node.QueueStatus.OldestAge
|
|
if oldestAge > 0 {
|
|
oldestMinutes := oldestAge / 60
|
|
alertID := fmt.Sprintf("%s-%s-oldest-message", pmg.ID, node.Name)
|
|
if (scaledAgeCrit <= 0 || oldestMinutes < int64(scaledAgeCrit)) && (scaledAgeWarn <= 0 || oldestMinutes < int64(scaledAgeWarn)) {
|
|
m.clearAlert(buildCanonicalStateID(pmg.ID, alertID))
|
|
continue
|
|
}
|
|
|
|
spec, err := buildCanonicalSeverityThresholdSpec(alertID, pmg.ID, pmg.Name, unifiedresources.ResourceTypePMG, "message-age", float64(scaledAgeWarn), float64(scaledAgeCrit), false)
|
|
if err != nil {
|
|
log.Warn().Err(err).Str("pmg", pmg.Name).Str("alertID", alertID).Msg("Skipping invalid canonical PMG node message-age spec")
|
|
continue
|
|
}
|
|
_, _ = m.evaluateCanonicalStatefulAlert(canonicalStatefulAlertParams{
|
|
Spec: spec,
|
|
Evidence: alertspecs.AlertEvidence{
|
|
ObservedAt: time.Now(),
|
|
SeverityThreshold: &alertspecs.SeverityThresholdEvidence{
|
|
Metric: "message-age",
|
|
Direction: alertspecs.ThresholdDirectionAbove,
|
|
Observed: float64(oldestMinutes),
|
|
},
|
|
},
|
|
AlertID: alertID,
|
|
AlertType: "message-age",
|
|
ResourceID: pmg.ID,
|
|
ResourceName: pmg.Name,
|
|
Node: node.Name,
|
|
Instance: pmg.Name,
|
|
MessageBuilder: func(result alertspecs.EvaluationResult) (string, float64, float64) {
|
|
currentThreshold := thresholdForCanonicalSeverity(result.State.Severity, float64(scaledAgeWarn), float64(scaledAgeCrit))
|
|
return fmt.Sprintf("PMG node %s on %s has messages queued for %d minutes (threshold: %d min, node-specific)", node.Name, pmg.Name, oldestMinutes, int(currentThreshold)), float64(oldestMinutes), currentThreshold
|
|
},
|
|
DispatchAsync: true,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// isQueueOutlier determines if a node's queue value is a significant outlier
|
|
// Returns true if value is >40% above the median across all nodes
|
|
func isQueueOutlier(value, median int) bool {
|
|
if median == 0 {
|
|
return value > 0
|
|
}
|
|
percentAboveMedian := float64(value-median) / float64(median) * 100
|
|
return percentAboveMedian > 40
|
|
}
|
|
|
|
// scaleThreshold applies a scaling factor to a threshold and ensures minimum value of 1
|
|
// Uses ceiling to avoid truncation issues with small thresholds
|
|
func scaleThreshold(threshold int, scaleFactor float64) int {
|
|
if threshold <= 0 {
|
|
return 0
|
|
}
|
|
scaled := int(math.Ceil(float64(threshold) * scaleFactor))
|
|
if scaled < 1 {
|
|
return 1
|
|
}
|
|
return scaled
|
|
}
|
|
|
|
// calculateMedianInt calculates median of integer slice
|
|
func calculateMedianInt(values []int) int {
|
|
if len(values) == 0 {
|
|
return 0
|
|
}
|
|
|
|
// Copy and sort
|
|
sorted := make([]int, len(values))
|
|
copy(sorted, values)
|
|
for i := 0; i < len(sorted); i++ {
|
|
for j := i + 1; j < len(sorted); j++ {
|
|
if sorted[i] > sorted[j] {
|
|
sorted[i], sorted[j] = sorted[j], sorted[i]
|
|
}
|
|
}
|
|
}
|
|
|
|
mid := len(sorted) / 2
|
|
if len(sorted)%2 == 0 {
|
|
return (sorted[mid-1] + sorted[mid]) / 2
|
|
}
|
|
return sorted[mid]
|
|
}
|
|
|
|
// checkPMGQuarantineBacklog checks quarantine backlog and growth rates
|
|
func (m *Manager) checkPMGQuarantineBacklog(pmg models.PMGInstance, defaults PMGThresholdConfig) {
|
|
if pmg.Quarantine == nil {
|
|
// A disabled or failed collector supplies no recovery evidence.
|
|
return
|
|
}
|
|
|
|
now := time.Now()
|
|
currentSpam := pmg.Quarantine.Spam
|
|
currentVirus := pmg.Quarantine.Virus
|
|
|
|
// Store current snapshot
|
|
m.mu.Lock()
|
|
snapshot := pmgQuarantineSnapshot{
|
|
Spam: currentSpam,
|
|
Virus: currentVirus,
|
|
Timestamp: now,
|
|
}
|
|
|
|
// Get or create history for this PMG instance
|
|
history := m.pmgQuarantineHistory[pmg.ID]
|
|
history = append(history, snapshot)
|
|
|
|
// Clean old snapshots (keep last 3 hours)
|
|
cutoff := now.Add(-3 * time.Hour)
|
|
validSnapshots := make([]pmgQuarantineSnapshot, 0, len(history))
|
|
for _, snap := range history {
|
|
if snap.Timestamp.After(cutoff) {
|
|
validSnapshots = append(validSnapshots, snap)
|
|
}
|
|
}
|
|
// Limit to max 48 samples to prevent unbounded growth
|
|
const maxQuarantineSnapshots = 48
|
|
if len(validSnapshots) > maxQuarantineSnapshots {
|
|
validSnapshots = validSnapshots[len(validSnapshots)-maxQuarantineSnapshots:]
|
|
}
|
|
m.pmgQuarantineHistory[pmg.ID] = validSnapshots
|
|
m.mu.Unlock()
|
|
|
|
// Find snapshot from ~2 hours ago (within ±15 min tolerance)
|
|
var twoHoursAgo *pmgQuarantineSnapshot
|
|
targetTime := now.Add(-2 * time.Hour)
|
|
minDiff := 15 * time.Minute
|
|
|
|
for i := range validSnapshots {
|
|
snap := &validSnapshots[i]
|
|
diff := snap.Timestamp.Sub(targetTime)
|
|
if diff < 0 {
|
|
diff = -diff
|
|
}
|
|
if diff < minDiff {
|
|
minDiff = diff
|
|
twoHoursAgo = snap
|
|
}
|
|
}
|
|
|
|
// Check spam quarantine
|
|
m.checkQuarantineMetric(pmg, "spam", currentSpam, twoHoursAgo, defaults)
|
|
|
|
// Check virus quarantine
|
|
m.checkQuarantineMetric(pmg, "virus", currentVirus, twoHoursAgo, defaults)
|
|
}
|
|
|
|
// checkQuarantineMetric checks a single quarantine metric (spam or virus)
|
|
func (m *Manager) checkQuarantineMetric(pmg models.PMGInstance, metricType string, current int, twoHoursAgo *pmgQuarantineSnapshot, defaults PMGThresholdConfig) {
|
|
alertID := fmt.Sprintf("%s-quarantine-%s", pmg.ID, metricType)
|
|
|
|
var absoluteWarn, absoluteCrit int
|
|
var previousCount int
|
|
|
|
// Get thresholds and previous count based on metric type
|
|
if metricType == "spam" {
|
|
absoluteWarn = defaults.QuarantineSpamWarn
|
|
absoluteCrit = defaults.QuarantineSpamCritical
|
|
if twoHoursAgo != nil {
|
|
previousCount = twoHoursAgo.Spam
|
|
}
|
|
} else { // virus
|
|
absoluteWarn = defaults.QuarantineVirusWarn
|
|
absoluteCrit = defaults.QuarantineVirusCritical
|
|
if twoHoursAgo != nil {
|
|
previousCount = twoHoursAgo.Virus
|
|
}
|
|
}
|
|
|
|
// A growth level needs both its percentage and its minimum message
|
|
// growth; either one set to Off (0) turns that level off. Passing one
|
|
// without the other made the spec invalid, which skipped the whole
|
|
// quarantine check, absolute thresholds included.
|
|
growthWarnPct, growthWarnMin := defaults.QuarantineGrowthWarnPct, defaults.QuarantineGrowthWarnMin
|
|
if growthWarnPct <= 0 || growthWarnMin <= 0 {
|
|
growthWarnPct, growthWarnMin = 0, 0
|
|
}
|
|
growthCritPct, growthCritMin := defaults.QuarantineGrowthCritPct, defaults.QuarantineGrowthCritMin
|
|
if growthCritPct <= 0 || growthCritMin <= 0 {
|
|
growthCritPct, growthCritMin = 0, 0
|
|
}
|
|
|
|
if absoluteWarn <= 0 && absoluteCrit <= 0 && growthWarnMin <= 0 && growthCritMin <= 0 {
|
|
m.clearAlert(buildCanonicalStateID(pmg.ID, alertID))
|
|
return
|
|
}
|
|
|
|
spec, err := buildCanonicalChangeThresholdSpec(
|
|
alertID,
|
|
pmg.ID,
|
|
pmg.Name,
|
|
unifiedresources.ResourceTypePMG,
|
|
"quarantine-"+metricType,
|
|
float64(absoluteWarn),
|
|
float64(absoluteCrit),
|
|
float64(growthWarnMin),
|
|
float64(growthCritMin),
|
|
float64(growthWarnPct),
|
|
float64(growthCritPct),
|
|
2*time.Hour,
|
|
false,
|
|
)
|
|
if err != nil {
|
|
log.Warn().
|
|
Err(err).
|
|
Str("pmg", pmg.Name).
|
|
Str("alertID", alertID).
|
|
Str("metricType", metricType).
|
|
Msg("Skipping invalid canonical PMG quarantine spec")
|
|
return
|
|
}
|
|
|
|
evidence := alertspecs.AlertEvidence{
|
|
ObservedAt: time.Now(),
|
|
ChangeThreshold: &alertspecs.ChangeThresholdEvidence{
|
|
Metric: "quarantine-" + metricType,
|
|
Observed: float64(current),
|
|
},
|
|
}
|
|
if previousCount > 0 {
|
|
previous := float64(previousCount)
|
|
evidence.ChangeThreshold.PreviousObserved = &previous
|
|
}
|
|
|
|
result, _ := m.evaluateCanonicalStatefulAlert(canonicalStatefulAlertParams{
|
|
Spec: spec,
|
|
Evidence: evidence,
|
|
AlertID: alertID,
|
|
AlertType: fmt.Sprintf("quarantine-%s", metricType),
|
|
ResourceID: pmg.ID,
|
|
ResourceName: pmg.Name,
|
|
Node: pmg.Host,
|
|
Instance: pmg.Name,
|
|
MessageBuilder: func(result alertspecs.EvaluationResult) (string, float64, float64) {
|
|
reason := result.State.Reason
|
|
threshold := quarantineAlertThreshold(metricType, reason, previousCount, defaults)
|
|
return quarantineAlertMessage(pmg, metricType, current, previousCount, reason, defaults), float64(current), threshold
|
|
},
|
|
DispatchAsync: true,
|
|
})
|
|
|
|
if result.Transition != nil && result.Transition.Kind == alertspecs.EvaluationTransitionActivated {
|
|
level, ok := alertLevelFromCanonicalSeverity(result.State.Severity)
|
|
if !ok {
|
|
level = AlertLevelWarning
|
|
}
|
|
log.Warn().
|
|
Str("pmg", pmg.Name).
|
|
Str("type", metricType).
|
|
Int("current", current).
|
|
Int("threshold", int(quarantineAlertThreshold(metricType, result.State.Reason, previousCount, defaults))).
|
|
Str("level", string(level)).
|
|
Msg("PMG quarantine backlog alert triggered")
|
|
}
|
|
}
|
|
|
|
// calculateTrimmedBaseline computes a robust baseline from historical samples
|
|
// using trimmed mean with median fallback for statistical robustness
|
|
func calculateTrimmedBaseline(samples []float64) (baseline float64, trustworthy bool) {
|
|
sampleCount := len(samples)
|
|
|
|
// Need at least 12 samples for trustworthy baseline (warmup period)
|
|
if sampleCount < 12 {
|
|
return 0, false
|
|
}
|
|
|
|
// For full 24-sample baseline, use trimmed mean
|
|
if sampleCount >= 24 {
|
|
// Create a copy for sorting
|
|
sorted := make([]float64, len(samples))
|
|
copy(sorted, samples)
|
|
|
|
// Sort samples
|
|
for i := 0; i < len(sorted); i++ {
|
|
for j := i + 1; j < len(sorted); j++ {
|
|
if sorted[i] > sorted[j] {
|
|
sorted[i], sorted[j] = sorted[j], sorted[i]
|
|
}
|
|
}
|
|
}
|
|
|
|
// Calculate median
|
|
var median float64
|
|
mid := len(sorted) / 2
|
|
if len(sorted)%2 == 0 {
|
|
median = (sorted[mid-1] + sorted[mid]) / 2
|
|
} else {
|
|
median = sorted[mid]
|
|
}
|
|
|
|
// Calculate trimmed mean: drop top and bottom 2, average remaining 20
|
|
if len(sorted) >= 24 {
|
|
trimmed := sorted[2 : len(sorted)-2]
|
|
sum := 0.0
|
|
for _, val := range trimmed {
|
|
sum += val
|
|
}
|
|
trimmedMean := sum / float64(len(trimmed))
|
|
|
|
// Fallback rule: if trimmed mean differs from median by >40%, use median
|
|
diff := trimmedMean - median
|
|
if diff < 0 {
|
|
diff = -diff
|
|
}
|
|
percentDiff := (diff / median) * 100
|
|
|
|
if percentDiff > 40 {
|
|
return median, true
|
|
}
|
|
return trimmedMean, true
|
|
}
|
|
}
|
|
|
|
// For 12-23 samples, use simple mean (not enough for trimming)
|
|
sum := 0.0
|
|
for _, val := range samples {
|
|
sum += val
|
|
}
|
|
return sum / float64(len(samples)), true
|
|
}
|
|
|
|
func anomalyAlertMessage(pmg models.PMGInstance, metricName string, current, baseline float64) string {
|
|
ratio := 0.0
|
|
if baseline > 0 {
|
|
ratio = current / baseline
|
|
}
|
|
return fmt.Sprintf("PMG %s anomaly detected: %s is %.1f messages/hour (%.1fx baseline of %.1f)", pmg.Name, metricName, current, ratio, baseline)
|
|
}
|
|
|
|
// checkPMGAnomalies detects spam/virus rate anomalies using trimmed baseline
|
|
func (m *Manager) checkPMGAnomalies(pmg models.PMGInstance, _ PMGThresholdConfig) {
|
|
// Need mail count data
|
|
if len(pmg.MailCount) == 0 {
|
|
return
|
|
}
|
|
|
|
// Get the latest hourly sample (most recent)
|
|
latest := pmg.MailCount[len(pmg.MailCount)-1]
|
|
now := time.Now()
|
|
|
|
// Get or create anomaly tracker for this PMG instance
|
|
m.mu.Lock()
|
|
tracker := m.pmgAnomalyTrackers[pmg.ID]
|
|
if tracker == nil {
|
|
tracker = &pmgAnomalyTracker{
|
|
Samples: make([]pmgMailMetricSample, 0, 48),
|
|
}
|
|
m.pmgAnomalyTrackers[pmg.ID] = tracker
|
|
}
|
|
|
|
// Create sample from latest mail count
|
|
sample := pmgMailMetricSample{
|
|
SpamIn: latest.SpamIn,
|
|
SpamOut: latest.SpamOut,
|
|
VirusIn: latest.VirusIn,
|
|
VirusOut: latest.VirusOut,
|
|
Timestamp: latest.Timestamp,
|
|
}
|
|
|
|
// Check for duplicate timestamp (already processed this sample)
|
|
if !tracker.LastSampleTime.IsZero() && !sample.Timestamp.After(tracker.LastSampleTime) {
|
|
m.mu.Unlock()
|
|
return
|
|
}
|
|
|
|
// Check for timestamp gaps (>90 min indicates data discontinuity)
|
|
if !tracker.LastSampleTime.IsZero() {
|
|
gap := sample.Timestamp.Sub(tracker.LastSampleTime)
|
|
if gap > 90*time.Minute {
|
|
// Discard old samples - data gap detected
|
|
log.Debug().
|
|
Str("pmg", pmg.Name).
|
|
Dur("gap", gap).
|
|
Msg("PMG mail count data gap detected, resetting anomaly history")
|
|
tracker.Samples = make([]pmgMailMetricSample, 0, 48)
|
|
tracker.SampleCount = 0
|
|
}
|
|
}
|
|
|
|
// Add sample to ring buffer
|
|
tracker.Samples = append(tracker.Samples, sample)
|
|
tracker.SampleCount++
|
|
tracker.LastSampleTime = sample.Timestamp
|
|
|
|
// Maintain ring buffer size (keep last 48)
|
|
if len(tracker.Samples) > 48 {
|
|
tracker.Samples = tracker.Samples[len(tracker.Samples)-48:]
|
|
}
|
|
|
|
sampleCount := len(tracker.Samples)
|
|
m.mu.Unlock()
|
|
|
|
// Need at least 12 samples for baseline warmup
|
|
if sampleCount < 12 {
|
|
log.Debug().
|
|
Str("pmg", pmg.Name).
|
|
Int("samples", sampleCount).
|
|
Msg("PMG anomaly detection warming up (need 12 samples)")
|
|
return
|
|
}
|
|
|
|
// Calculate baselines and check each metric
|
|
metrics := []struct {
|
|
name string
|
|
current float64
|
|
extractor func(pmgMailMetricSample) float64
|
|
}{
|
|
{"spamIn", sample.SpamIn, func(s pmgMailMetricSample) float64 { return s.SpamIn }},
|
|
{"spamOut", sample.SpamOut, func(s pmgMailMetricSample) float64 { return s.SpamOut }},
|
|
{"virusIn", sample.VirusIn, func(s pmgMailMetricSample) float64 { return s.VirusIn }},
|
|
{"virusOut", sample.VirusOut, func(s pmgMailMetricSample) float64 { return s.VirusOut }},
|
|
}
|
|
|
|
for _, metric := range metrics {
|
|
m.checkAnomalyMetric(pmg, tracker, metric.name, metric.current, metric.extractor, now)
|
|
}
|
|
}
|
|
|
|
// checkAnomalyMetric checks a single spam/virus metric for anomalies
|
|
func (m *Manager) checkAnomalyMetric(pmg models.PMGInstance, tracker *pmgAnomalyTracker, metricName string, current float64, extractor func(pmgMailMetricSample) float64, now time.Time) {
|
|
// Extract historical values for this metric (excluding current sample)
|
|
m.mu.RLock()
|
|
samples := tracker.Samples
|
|
m.mu.RUnlock()
|
|
|
|
if len(samples) < 2 {
|
|
return
|
|
}
|
|
|
|
// Get previous 24 samples (or all available if less than 25 total)
|
|
startIdx := 0
|
|
if len(samples) > 25 {
|
|
startIdx = len(samples) - 25
|
|
}
|
|
historicalSamples := samples[startIdx : len(samples)-1] // Exclude current (last) sample
|
|
|
|
// Extract metric values
|
|
values := make([]float64, 0, len(historicalSamples))
|
|
for _, s := range historicalSamples {
|
|
values = append(values, extractor(s))
|
|
}
|
|
|
|
// Calculate baseline
|
|
baseline, trustworthy := calculateTrimmedBaseline(values)
|
|
if !trustworthy {
|
|
return
|
|
}
|
|
|
|
alertID := fmt.Sprintf("%s-anomaly-%s", pmg.ID, metricName)
|
|
spec, err := buildCanonicalBaselineAnomalySpec(alertID, pmg.ID, pmg.Name, unifiedresources.ResourceTypePMG, metricName, 2, false)
|
|
if err != nil {
|
|
log.Warn().
|
|
Err(err).
|
|
Str("pmg", pmg.Name).
|
|
Str("metric", metricName).
|
|
Str("alertID", alertID).
|
|
Msg("Skipping invalid canonical PMG anomaly spec")
|
|
return
|
|
}
|
|
|
|
result, _ := m.evaluateCanonicalStatefulAlert(canonicalStatefulAlertParams{
|
|
Spec: spec,
|
|
Evidence: alertspecs.AlertEvidence{ObservedAt: now, BaselineAnomaly: &alertspecs.BaselineAnomalyEvidence{Metric: metricName, Observed: current, Baseline: baseline}},
|
|
AlertID: alertID,
|
|
AlertType: fmt.Sprintf("anomaly-%s", metricName),
|
|
ResourceID: pmg.ID,
|
|
ResourceName: pmg.Name,
|
|
Node: pmg.Host,
|
|
Instance: pmg.Name,
|
|
MessageBuilder: func(_ alertspecs.EvaluationResult) (string, float64, float64) {
|
|
return anomalyAlertMessage(pmg, metricName, current, baseline), current, baseline
|
|
},
|
|
DispatchAsync: true,
|
|
})
|
|
|
|
if result.State.State == alertspecs.AlertStatePending {
|
|
log.Debug().
|
|
Str("pmg", pmg.Name).
|
|
Str("metric", metricName).
|
|
Float64("current", current).
|
|
Float64("baseline", baseline).
|
|
Msg("PMG anomaly pending confirmation (first sample)")
|
|
return
|
|
}
|
|
|
|
if result.Transition != nil && result.Transition.Kind == alertspecs.EvaluationTransitionActivated {
|
|
pendingSince := result.Previous.FirstMatchedAt
|
|
if pendingSince.IsZero() {
|
|
pendingSince = now
|
|
}
|
|
|
|
level, ok := alertLevelFromCanonicalSeverity(result.State.Severity)
|
|
if !ok {
|
|
level = AlertLevelWarning
|
|
}
|
|
ratio := 0.0
|
|
effectiveBaseline := baseline
|
|
if effectiveBaseline == 0 && current > 0 {
|
|
effectiveBaseline = 1
|
|
}
|
|
if effectiveBaseline > 0 {
|
|
ratio = current / effectiveBaseline
|
|
}
|
|
log.Debug().
|
|
Str("pmg", pmg.Name).
|
|
Str("metric", metricName).
|
|
Float64("current", current).
|
|
Float64("baseline", baseline).
|
|
Dur("pending", now.Sub(pendingSince)).
|
|
Msg("PMG anomaly confirmed (second sample)")
|
|
|
|
log.Warn().
|
|
Str("pmg", pmg.Name).
|
|
Str("metric", metricName).
|
|
Float64("current", current).
|
|
Float64("baseline", baseline).
|
|
Float64("ratio", ratio).
|
|
Str("level", string(level)).
|
|
Msg("PMG anomaly alert triggered")
|
|
}
|
|
}
|