Pulse/internal/alerts/notification_policy.go
2026-06-29 22:27:01 +01:00

839 lines
27 KiB
Go

package alerts
import (
"strings"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
"github.com/rs/zerolog/log"
)
const (
// MetadataQuietHoursSuppressed marks notifications that were held by alert
// quiet-hours policy and must be replayed by the notification queue.
MetadataQuietHoursSuppressed = "quietHoursSuppressed"
// MetadataQuietHoursSuppressionReason records the quiet-hours rule that
// deferred notification delivery.
MetadataQuietHoursSuppressionReason = "quietHoursSuppressionReason"
// MetadataQuietHoursReplayAt stores the earliest RFC3339 UTC time that the
// notification queue may retry delivery.
MetadataQuietHoursReplayAt = "quietHoursReplayAt"
)
const (
AlertDeliveryStatusWouldSend = "would_send"
AlertDeliveryStatusDeferred = "deferred"
AlertDeliveryStatusSuppressed = "suppressed"
AlertDeliveryReasonReady = "ready"
AlertDeliveryReasonAcknowledged = "acknowledged"
AlertDeliveryReasonNotificationsDisabled = "notifications_disabled"
AlertDeliveryReasonNotificationsInactive = "notifications_inactive"
AlertDeliveryReasonSuppressionWindow = "suppression_window"
AlertDeliveryReasonFlapping = "flapping"
AlertDeliveryReasonRateLimited = "rate_limited"
AlertDeliveryReasonCooldown = "cooldown"
AlertDeliveryReasonQuietHours = "quiet_hours"
AlertDeliveryReasonMonitorOnly = "monitor_only"
)
// AlertDeliveryDiagnosis is a read-only projection of the alert manager's
// current delivery policy for one active alert. It is intentionally
// non-mutating: it does not dispatch callbacks or advance flapping/rate-limit
// counters.
type AlertDeliveryDiagnosis struct {
AlertIdentifier string `json:"alertIdentifier"`
AlertID string `json:"alertId"`
TrackingKey string `json:"trackingKey"`
Status string `json:"status"`
Reason string `json:"reason"`
Message string `json:"message"`
AlertType string `json:"alertType"`
Level AlertLevel `json:"level"`
ResourceID string `json:"resourceId,omitempty"`
ResourceName string `json:"resourceName,omitempty"`
Node string `json:"node,omitempty"`
NotificationsEnabled bool `json:"notificationsEnabled"`
ActivationState string `json:"activationState"`
CooldownMinutes int `json:"cooldownMinutes"`
MaxAlertsHour int `json:"maxAlertsHour"`
RecentAlertsInHour int `json:"recentAlertsInHour"`
FlappingActive bool `json:"flappingActive"`
FlappingHistoryInWindow int `json:"flappingHistoryInWindow"`
FlappingThreshold int `json:"flappingThreshold"`
FlappingWindowSeconds int `json:"flappingWindowSeconds"`
LastNotified *time.Time `json:"lastNotified,omitempty"`
NextEligibleAt *time.Time `json:"nextEligibleAt,omitempty"`
QuietHoursReplayAt *time.Time `json:"quietHoursReplayAt,omitempty"`
SuppressedUntil *time.Time `json:"suppressedUntil,omitempty"`
}
// checkFlappingLocked detects alert flapping and returns whether the alert
// should be suppressed and whether this call is the first transition into
// the flapping state for the current cooldown window. justTransitioned is
// the signal callers use to fire a one-shot postmortem hook on first
// detection -- it is true only on the call that flips
// flappingActive[trackingKey] from false to true.
//
// It modifies flappingHistory, flappingActive, and suppressedUntil maps.
// IMPORTANT: Caller MUST hold m.mu before calling this function. The
// transition signal is returned so the caller can dispatch the
// flapping-detected callback OUTSIDE the lock (typically via a goroutine).
func (m *Manager) checkFlappingLocked(trackingKey string) (suppress bool, justTransitioned bool) {
if !m.config.FlappingEnabled {
return false, false
}
now := time.Now()
windowDuration := time.Duration(m.config.FlappingWindowSeconds) * time.Second
// Record this state change
m.flappingHistory[trackingKey] = append(m.flappingHistory[trackingKey], now)
// Remove state changes outside the window
history := m.flappingHistory[trackingKey]
validHistory := []time.Time{}
for _, t := range history {
if now.Sub(t) <= windowDuration {
validHistory = append(validHistory, t)
}
}
// Limit to max 10 entries to prevent unbounded growth
const maxFlappingHistory = 10
if len(validHistory) > maxFlappingHistory {
validHistory = validHistory[len(validHistory)-maxFlappingHistory:]
}
m.flappingHistory[trackingKey] = validHistory
// Check if we've exceeded the threshold
if len(validHistory) >= m.config.FlappingThreshold {
// Mark as flapping
if !m.flappingActive[trackingKey] {
log.Warn().
Str("trackingKey", trackingKey).
Int("stateChanges", len(validHistory)).
Int("threshold", m.config.FlappingThreshold).
Int("windowSeconds", m.config.FlappingWindowSeconds).
Msg("Flapping detected - suppressing alert")
m.flappingActive[trackingKey] = true
// Set cooldown period
cooldownDuration := time.Duration(m.config.FlappingCooldownMinutes) * time.Minute
m.suppressedUntil[trackingKey] = now.Add(cooldownDuration)
// Record suppression metric
if recordAlertSuppressed != nil {
recordAlertSuppressed("flapping")
}
return true, true
}
return true, false
}
return false, false
}
func (m *Manager) dispatchAlert(alert *Alert, async bool) bool {
callbacks := m.getAlertCallbacks()
if len(callbacks) == 0 || alert == nil {
return false
}
// Don't dispatch notifications for acknowledged alerts
if alert.Acknowledged {
log.Debug().
Str("alertID", alert.ID).
Str("ackUser", alert.AckUser).
Msg("Alert notification suppressed - already acknowledged")
return false
}
trackingKey := canonicalTrackingKeyForAlert(alert)
// Check for flapping (caller must hold m.mu). When this call is the
// first transition into the flapping state for the current cooldown
// window, fire the flapping-detected callback so a postmortem patrol
// can be triggered. The callback runs in its own goroutine because
// the caller of dispatchAlert holds m.mu and the callback is allowed
// to take its own locks or re-enter the alerts package.
suppress, justTransitioned := m.checkFlappingLocked(trackingKey)
if justTransitioned {
cb := m.callbacks.flappingDetectedCallback()
if cb != nil {
alertCopy := cloneAlertForOutput(alert)
go func(a *Alert, key string, fn func(*Alert, string)) {
defer func() {
if r := recover(); r != nil {
log.Error().
Interface("panic", r).
Str("alertID", a.ID).
Str("trackingKey", key).
Msg("Panic in onFlappingDetected callback")
}
}()
fn(a, key)
}(alertCopy, trackingKey, cb)
}
}
if suppress {
log.Debug().
Str("alertID", alert.ID).
Str("trackingKey", trackingKey).
Msg("Alert suppressed due to flapping")
return false
}
// Check activation state - only dispatch notifications if active
if m.config.ActivationState != ActivationActive {
log.Debug().
Str("alertID", alert.ID).
Str("activationState", string(m.config.ActivationState)).
Msg("Alert notification suppressed - not activated")
return false
}
if suppressed, reason := m.shouldSuppressNotification(alert); suppressed {
replayAt := m.quietHoursReplayAt()
markQuietHoursNotificationReplay(alert, reason, replayAt)
log.Debug().
Str("alertID", alert.ID).
Str("type", alert.Type).
Str("level", string(alert.Level)).
Str("quietHoursRule", reason).
Time("replayAt", replayAt).
Msg("Alert notification deferred during quiet hours")
} else {
clearQuietHoursNotificationReplay(alert)
}
if isMonitorOnlyAlert(alert) {
log.Info().
Str("alertID", alert.ID).
Str("resource", alert.ResourceName).
Bool("monitorOnly", true).
Msg("Monitor-only alert detected, skipping alert dispatch")
return false
}
// Record metric for fired alert
if recordAlertFired != nil {
recordAlertFired(alert)
}
notifiedAt := time.Now()
m.recordAlertNotifiedNoLock(alert, notifiedAt)
m.saveActiveAlertsAsync("alert-dispatch")
alertCopy := cloneAlertForOutput(alert)
if async {
go func(a *Alert, fns []func(*Alert)) {
defer func() {
if r := recover(); r != nil {
log.Error().
Interface("panic", r).
Str("alertID", a.ID).
Str("type", a.Type).
Msg("Panic in onAlert callback")
}
}()
for _, callback := range fns {
callback(a)
}
}(alertCopy, callbacks)
} else {
// Synchronous calls also need panic recovery to prevent service crash
func(fns []func(*Alert)) {
defer func() {
if r := recover(); r != nil {
log.Error().
Interface("panic", r).
Str("alertID", alertCopy.ID).
Str("type", alertCopy.Type).
Msg("Panic in onAlert callback (synchronous)")
}
}()
for _, callback := range fns {
callback(alertCopy)
}
}(callbacks)
}
return true
}
func (m *Manager) recordAlertNotifiedNoLock(alert *Alert, notifiedAt time.Time) {
if alert == nil {
return
}
setAlertLastNotified(alert, notifiedAt)
if trackingKey := canonicalTrackingKeyForAlert(alert); trackingKey != "" {
if active, exists := m.getActiveAlertNoLock(trackingKey); exists && active != nil {
setAlertLastNotified(active, notifiedAt)
return
}
}
if alert.ID != "" {
if active, exists := m.getActiveAlertNoLock(alert.ID); exists && active != nil {
setAlertLastNotified(active, notifiedAt)
}
}
}
func setAlertLastNotified(alert *Alert, notifiedAt time.Time) {
if alert == nil {
return
}
t := notifiedAt
alert.LastNotified = &t
}
func isMonitorOnlyAlert(alert *Alert) bool {
if alert == nil || alert.Metadata == nil {
return false
}
if value, ok := alert.Metadata["monitorOnly"]; ok {
switch v := value.(type) {
case bool:
return v
case string:
return strings.EqualFold(v, "true")
}
}
return false
}
// isInQuietHours checks if the current time is within quiet hours
func (m *Manager) isInQuietHours() bool {
if !m.config.Schedule.QuietHours.Enabled {
return false
}
// Use cached location if available
loc := m.quietHoursLoc
if loc == nil {
// Fallback to loading if not cached yet (shouldn't happen with UpdateConfig)
var err error
loc, err = time.LoadLocation(m.config.Schedule.QuietHours.Timezone)
if err != nil {
log.Warn().Err(err).Str("timezone", m.config.Schedule.QuietHours.Timezone).Msg("failed to load timezone, using local time")
loc = time.Local
}
m.quietHoursLoc = loc
}
nowFn := m.now
if nowFn == nil {
nowFn = time.Now
}
now := nowFn().In(loc).Truncate(time.Minute)
dayName := strings.ToLower(now.Format("Monday"))
// Check if today is enabled for quiet hours
if enabled, ok := m.config.Schedule.QuietHours.Days[dayName]; !ok || !enabled {
return false
}
// Parse start and end times
startTime, err := time.ParseInLocation("15:04", m.config.Schedule.QuietHours.Start, loc)
if err != nil {
log.Warn().Err(err).Str("start", m.config.Schedule.QuietHours.Start).Msg("failed to parse quiet hours start time")
return false
}
endTime, err := time.ParseInLocation("15:04", m.config.Schedule.QuietHours.End, loc)
if err != nil {
log.Warn().Err(err).Str("end", m.config.Schedule.QuietHours.End).Msg("failed to parse quiet hours end time")
return false
}
// Set to today's date
startTime = time.Date(now.Year(), now.Month(), now.Day(), startTime.Hour(), startTime.Minute(), 0, 0, loc)
endTime = time.Date(now.Year(), now.Month(), now.Day(), endTime.Hour(), endTime.Minute(), 0, 0, loc)
// Quiet hours are configured with minute precision, so treat the start and
// end minute as inclusive for user-facing schedules such as 00:00-23:59.
endExclusive := endTime.Add(time.Minute)
// Handle overnight quiet hours (e.g., 22:00 to 08:00)
if endTime.Before(startTime) {
if !now.Before(startTime) || now.Before(endExclusive) {
return true
}
} else {
if !now.Before(startTime) && now.Before(endExclusive) {
return true
}
}
return false
}
func quietHoursCategoryForAlert(alert *Alert) string {
if alert == nil {
return ""
}
switch alert.Type {
case "cpu", "memory", "disk", "diskRead", "diskWrite", "networkIn", "networkOut", "temperature":
return "performance"
case "queue-depth", "queue-deferred", "queue-hold", "message-age",
"docker-container-health", "docker-container-restart-loop",
"docker-container-oom-kill", "docker-container-memory-limit":
return "performance"
case "usage", "disk-health", "disk-wearout", "zfs-pool-state", "zfs-pool-errors", "zfs-device", "storage-incident", "backup-storage-incident", "backup-posture-incident":
return "storage"
case "resource-incident":
if metadataStringValue(alert.Metadata, "incidentCategory") == unifiedresources.IncidentCategoryAvailability {
return "offline"
}
return "performance"
case "connectivity", "offline", "powered-off", "docker-host-offline":
return "offline"
}
if strings.HasPrefix(alert.Type, "docker-container-") {
if alert.Type == "docker-container-state" {
return "offline"
}
return "performance"
}
return ""
}
func (m *Manager) shouldSuppressNotification(alert *Alert) (bool, string) {
if alert == nil {
return false, ""
}
if !m.isInQuietHours() {
return false, ""
}
if alert.Level != AlertLevelCritical {
return true, "non-critical"
}
category := quietHoursCategoryForAlert(alert)
switch category {
case "performance":
if m.config.Schedule.QuietHours.Suppress.Performance {
return true, category
}
case "storage":
if m.config.Schedule.QuietHours.Suppress.Storage {
return true, category
}
case "offline":
if m.config.Schedule.QuietHours.Suppress.Offline {
return true, category
}
}
return false, ""
}
func (m *Manager) quietHoursReplayAt() time.Time {
nowFn := m.now
if nowFn == nil {
nowFn = time.Now
}
now := nowFn()
loc := m.quietHoursLoc
if loc == nil {
var err error
loc, err = time.LoadLocation(m.config.Schedule.QuietHours.Timezone)
if err != nil {
log.Warn().Err(err).Str("timezone", m.config.Schedule.QuietHours.Timezone).Msg("failed to load timezone for quiet-hours replay, using local time")
loc = time.Local
}
m.quietHoursLoc = loc
}
localNow := now.In(loc).Truncate(time.Minute)
startTime, startErr := time.ParseInLocation("15:04", m.config.Schedule.QuietHours.Start, loc)
endTime, endErr := time.ParseInLocation("15:04", m.config.Schedule.QuietHours.End, loc)
if startErr != nil || endErr != nil {
return now.Add(time.Minute).UTC()
}
startTime = time.Date(localNow.Year(), localNow.Month(), localNow.Day(), startTime.Hour(), startTime.Minute(), 0, 0, loc)
endTime = time.Date(localNow.Year(), localNow.Month(), localNow.Day(), endTime.Hour(), endTime.Minute(), 0, 0, loc)
endExclusive := endTime.Add(time.Minute)
if endTime.Before(startTime) && !localNow.Before(startTime) {
endExclusive = endExclusive.AddDate(0, 0, 1)
}
if !endExclusive.After(localNow) {
return now.Add(time.Minute).UTC()
}
return endExclusive.UTC()
}
func markQuietHoursNotificationReplay(alert *Alert, reason string, replayAt time.Time) {
if alert == nil {
return
}
if alert.Metadata == nil {
alert.Metadata = make(map[string]interface{}, 3)
}
alert.Metadata[MetadataQuietHoursSuppressed] = true
alert.Metadata[MetadataQuietHoursSuppressionReason] = reason
alert.Metadata[MetadataQuietHoursReplayAt] = replayAt.UTC().Format(time.RFC3339)
}
func clearQuietHoursNotificationReplay(alert *Alert) {
if alert == nil || alert.Metadata == nil {
return
}
delete(alert.Metadata, MetadataQuietHoursSuppressed)
delete(alert.Metadata, MetadataQuietHoursSuppressionReason)
delete(alert.Metadata, MetadataQuietHoursReplayAt)
}
func hasQuietHoursNotificationReplay(alert *Alert) bool {
if alert == nil || alert.Metadata == nil {
return false
}
if replayAt, ok := alert.Metadata[MetadataQuietHoursReplayAt].(string); ok && strings.TrimSpace(replayAt) != "" {
return true
}
if suppressed, ok := alert.Metadata[MetadataQuietHoursSuppressed].(bool); ok {
return suppressed
}
return false
}
// ShouldSuppressNotification checks whether a notification must be dropped at
// the alert-policy layer. Quiet-hours matches are replayable, so this helper
// annotates the alert for notification-queue retry and returns false to let
// bypass callers hand the alert to the delivery owner.
func (m *Manager) ShouldSuppressNotification(alert *Alert) bool {
if alert == nil {
return false
}
m.mu.RLock()
defer m.mu.RUnlock()
suppressed, reason := m.shouldSuppressNotification(alert)
if suppressed {
replayAt := m.quietHoursReplayAt()
markQuietHoursNotificationReplay(alert, reason, replayAt)
log.Debug().
Str("alertID", alert.ID).
Str("type", alert.Type).
Str("level", string(alert.Level)).
Str("quietHoursRule", reason).
Time("replayAt", replayAt).
Msg("Notification deferred during quiet hours")
return false
}
clearQuietHoursNotificationReplay(alert)
return false
}
// ShouldSuppressResolvedNotification checks if a recovery notification should be suppressed.
// Recovery notifications keep the existing quiet-hours rule unless the firing
// notification was already deferred into the owned queue replay path; in that
// case the resolved notification is also allowed to enter the queue so the
// close-the-loop delivery is not lost.
func (m *Manager) ShouldSuppressResolvedNotification(alert *Alert) bool {
if alert == nil {
return false
}
m.mu.RLock()
defer m.mu.RUnlock()
if alert.Acknowledged {
log.Debug().
Str("alertID", alert.ID).
Str("type", alert.Type).
Msg("Recovery notification suppressed for acknowledged alert")
return true
}
quietHoursReplay := hasQuietHoursNotificationReplay(alert)
if alert.LastNotified == nil && !quietHoursReplay {
log.Debug().
Str("alertID", alert.ID).
Str("type", alert.Type).
Msg("Recovery notification suppressed because firing notification was never sent")
return true
}
suppressed, reason := m.shouldSuppressNotification(alert)
if suppressed {
if quietHoursReplay {
log.Debug().
Str("alertID", alert.ID).
Str("type", alert.Type).
Str("level", string(alert.Level)).
Str("quietHoursRule", reason).
Msg("Recovery notification allowed into quiet-hours replay queue")
return false
}
log.Debug().
Str("alertID", alert.ID).
Str("type", alert.Type).
Str("level", string(alert.Level)).
Str("quietHoursRule", reason).
Msg("Recovery notification suppressed during quiet hours")
}
return suppressed
}
// shouldNotifyAfterCooldown decides whether a re-notification can be sent for
// an existing alert based on the configured cooldown.
//
// Cooldown semantics:
// - cooldown > 0: re-notify after that many minutes have passed since the
// last notification.
// - cooldown <= 0: cooldown is disabled, so only the first notification for
// an alert occurrence is allowed. The alert evaluation loop runs every
// metric tick, so treating disabled cooldown as "always allow" causes
// repeated notifications for the same active alert.
//
// Level-escalation re-notifications are handled separately at the call site.
// Returns true if a notification should be sent, false otherwise.
func (m *Manager) shouldNotifyAfterCooldown(alert *Alert) bool {
if alert.LastNotified == nil {
return true
}
if m.config.Schedule.Cooldown <= 0 {
return false
}
cooldownDuration := time.Duration(m.config.Schedule.Cooldown) * time.Minute
timeSinceLastNotification := time.Since(*alert.LastNotified)
return timeSinceLastNotification >= cooldownDuration
}
// DiagnoseAlertDelivery reports the current notification policy outcome for
// one active alert without sending a notification or updating any delivery
// tracking maps.
func (m *Manager) DiagnoseAlertDelivery(alertIdentifier string) (AlertDeliveryDiagnosis, bool) {
diagnosis := AlertDeliveryDiagnosis{
AlertIdentifier: strings.TrimSpace(alertIdentifier),
Status: AlertDeliveryStatusSuppressed,
Reason: "not_found",
}
if m == nil || diagnosis.AlertIdentifier == "" {
return diagnosis, false
}
m.mu.Lock()
defer m.mu.Unlock()
alert, exists := m.getActiveAlertNoLock(diagnosis.AlertIdentifier)
if !exists || alert == nil {
return diagnosis, false
}
now := m.policyNow()
trackingKey := canonicalTrackingKeyForAlert(alert)
diagnosis.AlertID = alert.ID
diagnosis.TrackingKey = trackingKey
diagnosis.AlertType = alert.Type
diagnosis.Level = alert.Level
diagnosis.ResourceID = alert.ResourceID
diagnosis.ResourceName = alert.ResourceName
diagnosis.Node = alert.Node
diagnosis.NotificationsEnabled = m.config.Enabled && m.config.ActivationState == ActivationActive
diagnosis.ActivationState = string(m.config.ActivationState)
diagnosis.CooldownMinutes = m.config.Schedule.Cooldown
diagnosis.MaxAlertsHour = m.config.Schedule.MaxAlertsHour
diagnosis.FlappingThreshold = m.config.FlappingThreshold
diagnosis.FlappingWindowSeconds = m.config.FlappingWindowSeconds
if alert.LastNotified != nil {
lastNotified := *alert.LastNotified
diagnosis.LastNotified = &lastNotified
}
diagnosis.FlappingActive, diagnosis.FlappingHistoryInWindow, diagnosis.SuppressedUntil = m.flappingSnapshotLocked(trackingKey, now)
diagnosis.RecentAlertsInHour = m.rateLimitCountLocked(trackingKey, now)
switch {
case alert.Acknowledged:
diagnosis.setSuppressed(AlertDeliveryReasonAcknowledged, "Alert is acknowledged, so firing notifications are suppressed.")
case !m.config.Enabled:
diagnosis.setSuppressed(AlertDeliveryReasonNotificationsDisabled, "Alert notifications are disabled in the alert configuration.")
case m.config.ActivationState != ActivationActive:
diagnosis.setSuppressed(AlertDeliveryReasonNotificationsInactive, "Alert notifications are not active yet.")
case diagnosis.SuppressedUntil != nil && diagnosis.SuppressedUntil.After(now):
reason := AlertDeliveryReasonSuppressionWindow
message := "Alert delivery is inside an active suppression window."
if diagnosis.FlappingActive {
reason = AlertDeliveryReasonFlapping
message = "Alert delivery is suppressed because this alert is flapping."
}
diagnosis.setSuppressed(reason, message)
case m.rateLimitExceededLocked(diagnosis.RecentAlertsInHour):
diagnosis.setSuppressed(AlertDeliveryReasonRateLimited, "Alert delivery has reached the configured per-hour rate limit.")
case alert.LastNotified != nil && !m.cooldownAllowsDeliveryLocked(alert, now):
diagnosis.setSuppressed(AlertDeliveryReasonCooldown, "Alert delivery is waiting for the configured re-notification cooldown.")
if next := m.nextCooldownEligibleAtLocked(alert); next != nil {
diagnosis.NextEligibleAt = next
}
case func() bool {
suppressed, reason := m.shouldSuppressNotification(alert)
if !suppressed {
return false
}
replayAt := m.quietHoursReplayAt()
diagnosis.QuietHoursReplayAt = &replayAt
if reason != "" {
diagnosis.Reason = AlertDeliveryReasonQuietHours + ":" + reason
} else {
diagnosis.Reason = AlertDeliveryReasonQuietHours
}
diagnosis.Status = AlertDeliveryStatusDeferred
diagnosis.Message = "Alert delivery is deferred by quiet-hours policy and will be replayed later."
return true
}():
case isMonitorOnlyAlert(alert):
diagnosis.setSuppressed(AlertDeliveryReasonMonitorOnly, "Alert is marked monitor-only, so it stays visible without notification delivery.")
default:
diagnosis.Status = AlertDeliveryStatusWouldSend
diagnosis.Reason = AlertDeliveryReasonReady
diagnosis.Message = "Alert delivery is currently eligible for notification delivery."
}
return diagnosis, true
}
func (d *AlertDeliveryDiagnosis) setSuppressed(reason, message string) {
d.Status = AlertDeliveryStatusSuppressed
d.Reason = reason
d.Message = message
}
func (m *Manager) policyNow() time.Time {
if m != nil && m.now != nil {
return m.now()
}
return time.Now()
}
func (m *Manager) flappingSnapshotLocked(trackingKey string, now time.Time) (bool, int, *time.Time) {
if trackingKey == "" || !m.config.FlappingEnabled {
return false, 0, nil
}
windowDuration := time.Duration(m.config.FlappingWindowSeconds) * time.Second
validCount := 0
for _, t := range m.flappingHistory[trackingKey] {
if now.Sub(t) <= windowDuration {
validCount++
}
}
var suppressedUntil *time.Time
if until, ok := m.suppressedUntil[trackingKey]; ok {
u := until
suppressedUntil = &u
}
return m.flappingActive[trackingKey], validCount, suppressedUntil
}
func (m *Manager) rateLimitCountLocked(trackingKey string, now time.Time) int {
if trackingKey == "" || m.config.Schedule.MaxAlertsHour <= 0 {
return 0
}
cutoff := now.Add(-1 * time.Hour)
recentCount := 0
for _, t := range m.alertRateLimit[trackingKey] {
if t.After(cutoff) {
recentCount++
}
}
return recentCount
}
func (m *Manager) rateLimitExceededLocked(recentCount int) bool {
return m.config.Schedule.MaxAlertsHour > 0 && recentCount >= m.config.Schedule.MaxAlertsHour
}
func (m *Manager) cooldownAllowsDeliveryLocked(alert *Alert, now time.Time) bool {
if alert == nil || alert.LastNotified == nil {
return true
}
if m.config.Schedule.Cooldown <= 0 {
return false
}
return !now.Before(alert.LastNotified.Add(time.Duration(m.config.Schedule.Cooldown) * time.Minute))
}
func (m *Manager) nextCooldownEligibleAtLocked(alert *Alert) *time.Time {
if alert == nil || alert.LastNotified == nil || m.config.Schedule.Cooldown <= 0 {
return nil
}
next := alert.LastNotified.Add(time.Duration(m.config.Schedule.Cooldown) * time.Minute)
return &next
}
func (m *Manager) allowNotificationByRateLimit(trackingKey string, alert *Alert, reason string) bool {
if trackingKey == "" && alert != nil {
trackingKey = canonicalTrackingKeyForAlert(alert)
}
if trackingKey == "" && alert != nil {
trackingKey = alert.ID
}
if m.checkRateLimit(trackingKey) {
return true
}
log.Debug().
Str("alertID", alertIDForLog(alert)).
Str("trackingKey", trackingKey).
Str("reason", reason).
Int("maxPerHour", m.config.Schedule.MaxAlertsHour).
Msg("Alert notification suppressed due to rate limit")
return false
}
func alertIDForLog(alert *Alert) string {
if alert == nil {
return ""
}
return alert.ID
}
// checkRateLimit checks if an alert has exceeded rate limit
func (m *Manager) checkRateLimit(alertID string) bool {
if m.config.Schedule.MaxAlertsHour <= 0 {
return true // No rate limit
}
now := time.Now()
cutoff := now.Add(-1 * time.Hour)
// Clean old entries and count recent alerts
var recentAlerts []time.Time
if times, exists := m.alertRateLimit[alertID]; exists {
for _, t := range times {
if t.After(cutoff) {
recentAlerts = append(recentAlerts, t)
}
}
}
// Check if we've hit the limit
if len(recentAlerts) >= m.config.Schedule.MaxAlertsHour {
return false
}
// Add current time
recentAlerts = append(recentAlerts, now)
m.alertRateLimit[alertID] = recentAlerts
return true
}