Documentation
¶
Overview ¶
Package alerting implements TASKS.md 2.5/2.7: threshold rules over internal/telemetry's metrics and logs, and crashloop detection as a built-in rule kind sharing the same evaluate/notify path rather than a separate mechanism (see TASKS.md 2.5's own note on why).
Index ¶
- Constants
- Variables
- func CertExpiryStatus(notAfter, now time.Time, warningWindow time.Duration) string
- func NewDeployTargetID() (string, error)
- func NewNotificationChannelID() (string, error)
- func NewNotificationDeliveryID() (string, error)
- func NewRuleID() (string, error)
- type AppDomainSource
- type CertExpiryObservation
- type CertExpiryObservationStore
- type CertInfo
- type CertSource
- type Comparator
- type DB
- func (db *DB) DeleteDeployTarget(ctx context.Context, id string) error
- func (db *DB) DeleteNotificationChannel(ctx context.Context, id string) error
- func (db *DB) DeleteRule(ctx context.Context, id string) error
- func (db *DB) GetDeployTarget(ctx context.Context, id string) (*DeployTarget, error)
- func (db *DB) GetNotificationChannel(ctx context.Context, id string) (*NotificationChannel, error)
- func (db *DB) GetRule(ctx context.Context, id string) (*Rule, error)
- func (db *DB) ListCertExpiryObservations(ctx context.Context, ruleID string) ([]CertExpiryObservation, error)
- func (db *DB) ListDeployTargetsForResource(ctx context.Context, resourceID string) ([]DeployTarget, error)
- func (db *DB) ListEnabledRules(ctx context.Context) ([]Rule, error)
- func (db *DB) ListNotificationChannels(ctx context.Context) ([]NotificationChannel, error)
- func (db *DB) ListNotificationDeliveries(ctx context.Context, channelID string, limit int, before *time.Time) ([]NotificationDelivery, error)
- func (db *DB) ListRules(ctx context.Context) ([]Rule, error)
- func (db *DB) ListRulesForResource(ctx context.Context, resourceID string) ([]Rule, error)
- func (db *DB) RecordNotificationDelivery(ctx context.Context, d NotificationDelivery) error
- func (db *DB) SaveDeployTarget(ctx context.Context, t DeployTarget) error
- func (db *DB) SaveNotificationChannel(ctx context.Context, c NotificationChannel) error
- func (db *DB) SaveRule(ctx context.Context, r Rule) error
- func (db *DB) UpdateState(ctx context.Context, id string, pendingSince, firingSince *time.Time, ...) error
- func (db *DB) UpsertCertExpiryObservation(ctx context.Context, o CertExpiryObservation) error
- type DeliveryRecorder
- type DeployDispatcher
- type DeployOutcome
- type DeployTarget
- type DomainCheckSource
- type Engine
- type Event
- type EventSource
- type Kind
- type LogsSource
- type MetricsSource
- type NodeAlertState
- type NodeAlertStatus
- type NodeServiceSource
- type NodeSource
- type NotificationChannel
- type NotificationDelivery
- type Notifier
- type NotifyKind
- type RestartTracker
- func (t *RestartTracker) CountSince(resourceID string, since time.Time) int
- func (t *RestartTracker) Observe(resourceID, containerName string, at time.Time)
- func (t *RestartTracker) Prune(olderThan time.Time)
- func (t *RestartTracker) Run(ctx context.Context, source EventSource, services ServiceLister, ...) error
- type Rule
- func EvaluateCertExpiry(ctx context.Context, certs CertSource, snapshots CertExpiryObservationStore, ...) (Rule, []string, error)
- func EvaluateCrashloop(tracker *RestartTracker, r Rule, now time.Time) Rule
- func EvaluateDomainHealth(ctx context.Context, apps AppDomainSource, checker DomainCheckSource, r Rule, ...) (Rule, []string, error)
- func EvaluateNodeDiskSpace(ctx context.Context, nodes NodeSource, metrics MetricsSource, r Rule, ...) (Rule, []string, error)
- func EvaluateNodeResourceUsage(ctx context.Context, nodes NodeSource, services NodeServiceSource, ...) (Rule, []string, error)
- func EvaluatePatchStatus(ctx context.Context, nodes NodeSource, metrics MetricsSource, r Rule, ...) (Rule, []string, error)
- func EvaluateScheduledTaskFailure(ctx context.Context, tasks ScheduledTaskSource, r Rule, now time.Time) (Rule, string, error)
- func EvaluateThreshold(ctx context.Context, source MetricsSource, r Rule, now time.Time) (Rule, error)
- type RuleStore
- type ScheduledTaskSource
- type ServiceLister
Constants ¶
const DefaultCertExpiryWarningWindow = 14 * 24 * time.Hour
DefaultCertExpiryWarningWindow is how far ahead of a certificate's NotAfter ListCertificates starts reporting "expiring_soon" instead of "healthy" when no override is configured. Also GET /api/v1/certificates' own default (api.Router.certExpiryWarningWindow), so the dashboard's TLS card and a kind=cert_expiry rule always agree on when a certificate first becomes a concern.
const DefaultCertRenewalStalledThreshold = 6 * time.Hour
DefaultCertRenewalStalledThreshold is how long a certificate can sit in "expiring_soon" or "expired" with an unchanged NotAfter before EvaluateCertExpiry treats it as a stronger "renewal appears stalled" signal rather than a plain expiry warning. Caddy's own ACME renewal starts at roughly a third of a certificate's remaining lifetime (~30 days out for a 90-day Let's Encrypt cert), well before the 14-day warning window even opens, so a real renewal attempt should already be long done by the time a certificate enters that window; several hours of the window elapsing with zero movement on NotAfter is a genuine anomaly, not just evaluation-tick jitter.
const DefaultDomainHealthCheckInterval = 5 * time.Minute
DefaultDomainHealthCheckInterval is how often a kind=domain_health rule performs a real DNS check, independent of Engine's own tick cadence, when no override is configured. The failure mode this rule exists to catch (a CNAME silently repointed away) unfolds over months, so checking far more often than Engine's 30s tick buys nothing but outbound DNS traffic; five minutes still catches drift long before "six months later."
const DefaultNodeCPUThresholdPercent = 80.0
DefaultNodeCPUThresholdPercent is how much summed CPU a node's placed containers can use before a kind=node_resource_usage rule fires on its CPU signal, when no override is configured. cpu_percent (collector.go's sampleValues) is already expressed on the same 0-100 scale nodeSummableMetrics (internal/api/node_metrics.go) sums across containers for the dashboard, so this threshold compares directly against that same already-user-facing number.
const DefaultNodeDiskSpaceThresholdPercent = 90.0
DefaultNodeDiskSpaceThresholdPercent is how full (percent of disk used) a node can get before a kind=node_disk_space rule fires, when no override is configured. HostDiskCollector only ever reports total and used bytes, so percent-used is the natural signal to threshold on (unlike doctorCheckDiskSpace's fixed free-byte floor, which does not scale across wildly different disk sizes).
const DefaultNodeMemoryThresholdBytes = 4 * 1024 * 1024 * 1024
DefaultNodeMemoryThresholdBytes is how much summed memory a node's placed containers can use before a kind=node_resource_usage rule fires on its memory signal, when no override is configured. Unlike disk (HostDiskCollector) there is no real host memory total anywhere in this codebase to divide by: memory_limit_bytes is deliberately excluded from nodeSummableMetrics because an unconstrained container's reported limit approximates host total memory, not a real per-container cap, so summing it would overcount rather than give an honest denominator (see nodeSummableMetrics' own doc comment). An absolute-bytes floor is the only honest signal available today; 4 GiB is a generic starting point operators are expected to tune to their own node capacity via APP_ALERT_NODE_MEMORY_THRESHOLD_BYTES.
const DefaultPatchStatusThreshold = 1
DefaultPatchStatusThreshold is how many pending security patches a node can have before a kind=patch_status rule fires, when no override is configured. hostpatch.go only ever reports a point-in-time count, never a per-package age, so count is the only signal this kind can evaluate; 1 means "any security patch pending at all," the same zero-tolerance default GET /api/v1/nodes/{id}/patch-status's dashboard card already implies by showing Security > 0 as a concern.
Variables ¶
var ErrDeployTargetNotFound = errors.New("alerting: deploy target not found")
ErrDeployTargetNotFound is returned by GetDeployTarget when no target has that ID.
var ErrNotificationChannelNotFound = errors.New("alerting: notification channel not found")
ErrNotificationChannelNotFound is returned by GetNotificationChannel and DeleteNotificationChannel when id doesn't match any row.
var ErrRuleNotFound = errors.New("alerting: rule not found")
ErrRuleNotFound is returned by GetRule when no rule has that ID.
Functions ¶
func CertExpiryStatus ¶
CertExpiryStatus buckets notAfter, relative to now, into the three states a certificate can be in: "expired" once notAfter has passed or is passing this instant, "expiring_soon" within warningWindow of it, "healthy" otherwise. A notAfter exactly warningWindow away is still "healthy": the window is when to start warning, not an inclusive boundary.
func NewDeployTargetID ¶
NewDeployTargetID generates a random, URL-safe deploy-target identifier, the same shape NewRuleID already mints for alert_rules: exported so internal/api can assign one when creating a target from a request body that doesn't include one, the identical "ID is never caller-chosen data" reasoning NewRuleID's own doc comment gives.
func NewNotificationChannelID ¶
NewNotificationChannelID mirrors NewDeployTargetID's exact shape.
func NewNotificationDeliveryID ¶
NewNotificationDeliveryID mirrors NewNotificationChannelID's exact shape.
Types ¶
type AppDomainSource ¶
type AppDomainSource interface {
GetDesiredService(ctx context.Context, name string) (*store.DesiredService, error)
}
AppDomainSource is the narrow store surface EvaluateDomainHealth needs: which domains a domain_health rule's own app currently has configured. *store.DB satisfies this structurally.
type CertExpiryObservation ¶
type CertExpiryObservation struct {
RuleID string
Domain string
Status string
NotAfter time.Time
EpisodeNotAfter time.Time
EpisodeStartedAt time.Time
ObservedAt time.Time
}
CertExpiryObservation is what EvaluateCertExpiry persists per (rule, domain) after every tick, so the next tick can tell "still stuck in the same unrenewed episode" apart from "just entered a fresh expiry warning because a real renewal landed." See EvaluateCertExpiry's own doc comment for how EpisodeNotAfter/EpisodeStartedAt are used.
type CertExpiryObservationStore ¶
type CertExpiryObservationStore interface {
UpsertCertExpiryObservation(ctx context.Context, o CertExpiryObservation) error
ListCertExpiryObservations(ctx context.Context, ruleID string) ([]CertExpiryObservation, error)
}
CertExpiryObservationStore is the narrow store surface EvaluateCertExpiry needs to remember what it saw last tick, per domain. *DB satisfies this structurally.
type CertInfo ¶
type CertInfo struct {
Domain string
SANs []string
Issuer string
NotBefore time.Time
NotAfter time.Time
// Status is "healthy", "expiring_soon", or "expired"; see
// CertExpiryStatus.
Status string
}
CertInfo is one stored certificate's expiry-relevant fields, the single computation both GET /api/v1/certificates (internal/api) and a kind=cert_expiry alert rule read via ListCertificates, so the two can never silently disagree about a certificate's status.
func ListCertificates ¶
func ListCertificates(ctx context.Context, source CertSource, warningWindow time.Duration, now time.Time, logger *slog.Logger) ([]CertInfo, error)
ListCertificates returns every certificate currently in source, parsed from its own stored PEM bytes, sorted by domain. A control plane that has never issued a certificate returns an empty slice, not an error. A single malformed or since-deleted entry is skipped and logged, never fails the whole list, the same "one broken resource must not block the rest" shape used throughout this codebase.
type CertSource ¶
type CertSource interface {
ListCertStorageKeys(ctx context.Context, prefix string, recursive bool) ([]string, error)
GetCertStorageValue(ctx context.Context, key string) (*store.CertStorageValue, error)
}
CertSource is the narrow storage surface a cert-expiry evaluation needs: the same two-method shape internal/api.CertStore already defines. Duplicated here rather than imported because internal/api imports internal/alerting (for AlertRules et al.), so the reverse import would cycle; *store.DB satisfies both interfaces structurally.
type Comparator ¶
type Comparator string
Comparator is how a threshold Rule compares the latest sample value against its Threshold.
const ( GreaterThan Comparator = ">" LessThan Comparator = "<" GreaterOrEqual Comparator = ">=" LessOrEqual Comparator = "<=" )
The four comparators a threshold Rule can use.
type DB ¶
DB wraps a *sql.DB opened against alerting.db, a dedicated SQLite file separate from both internal/store's levelrail.db and internal/telemetry's telemetry.db, the same write-isolation reasoning ADR 009 already applied: alert rule writes (evaluation state updates on every tick) are a different, independent write pattern from either desired-state config or metric/log ingestion.
func Open ¶
Open opens (creating if needed) the SQLite database at path, applies pragmas, and runs every pending migration, matching internal/store and internal/telemetry's own Open functions exactly.
func (*DB) DeleteDeployTarget ¶
DeleteDeployTarget removes a deploy target by ID. Deleting a target that doesn't exist is not an error, the same idempotent-delete convention DeleteRule already follows.
func (*DB) DeleteNotificationChannel ¶
DeleteNotificationChannel removes a channel row. Attached deploy_notify_targets rows have channel_id cleared automatically by migrations/0004's ON DELETE SET NULL, never blocking this delete.
func (*DB) DeleteRule ¶
DeleteRule removes a rule by ID. Deleting a rule that doesn't exist is not an error, matching internal/store.DeleteDesiredService's own idempotent-delete convention.
func (*DB) GetDeployTarget ¶
GetDeployTarget returns the deploy target with this ID, or ErrDeployTargetNotFound.
func (*DB) GetNotificationChannel ¶
GetNotificationChannel returns the channel with this ID, or ErrNotificationChannelNotFound.
func (*DB) ListCertExpiryObservations ¶
func (db *DB) ListCertExpiryObservations(ctx context.Context, ruleID string) ([]CertExpiryObservation, error)
ListCertExpiryObservations returns every observation row for ruleID, one per domain, unordered.
func (*DB) ListDeployTargetsForResource ¶
func (db *DB) ListDeployTargetsForResource(ctx context.Context, resourceID string) ([]DeployTarget, error)
ListDeployTargetsForResource returns every deploy target scoped to resourceID, ordered by created_at, regardless of enabled state: the same "list everything, let the caller decide what to show or use" shape ListRulesForResource already establishes, used both by internal/api's CRUD handlers (list including disabled targets, so an operator can see and re-enable a paused one) and by Dispatch below (which filters to enabled targets itself).
func (*DB) ListEnabledRules ¶
ListEnabledRules is what the evaluator (evaluate.go, crashloop.go) actually loops over each tick: a disabled rule is skipped entirely, not evaluated-but-not-notified, so a paused rule doesn't even pay for a metrics query.
func (*DB) ListNotificationChannels ¶
func (db *DB) ListNotificationChannels(ctx context.Context) ([]NotificationChannel, error)
ListNotificationChannels returns every channel, oldest first (creation order, the same order the settings page listing them wants).
func (*DB) ListNotificationDeliveries ¶
func (db *DB) ListNotificationDeliveries(ctx context.Context, channelID string, limit int, before *time.Time) ([]NotificationDelivery, error)
ListNotificationDeliveries returns up to limit delivery records for channelID, most recent first, cursor-paginated by before (an optional exclusive created_at cutoff), the same shape store.ListAuditEntries already uses. Callers are responsible for clamping limit.
func (*DB) ListRulesForResource ¶
ListRulesForResource returns every rule scoped to resourceID, ordered by name, regardless of enabled state. internal/api's alert-rule handlers (TASKS.md 2.5) use this to list only one app's own rules rather than every rule in the database; alert_rules already carries idx_alert_rules_resource (migrations/0001_alert_rules.sql) for exactly this lookup.
func (*DB) RecordNotificationDelivery ¶
func (db *DB) RecordNotificationDelivery(ctx context.Context, d NotificationDelivery) error
RecordNotificationDelivery persists one delivery attempt. Insert-only: a delivery record is never updated once written.
func (*DB) SaveDeployTarget ¶
func (db *DB) SaveDeployTarget(ctx context.Context, t DeployTarget) error
SaveDeployTarget creates or fully replaces a deploy target's configuration, the same upsert shape SaveRule already uses.
func (*DB) SaveNotificationChannel ¶
func (db *DB) SaveNotificationChannel(ctx context.Context, c NotificationChannel) error
SaveNotificationChannel creates or fully replaces a channel's configuration, the same upsert shape SaveDeployTarget already uses.
func (*DB) SaveRule ¶
SaveRule creates or fully replaces a rule's configuration. Does not touch evaluation state (Firing/FiringSince/etc.): those are only ever written by UpdateState, so an operator editing a rule's threshold doesn't accidentally reset its current firing status.
func (*DB) UpdateState ¶
func (db *DB) UpdateState(ctx context.Context, id string, pendingSince, firingSince *time.Time, firing bool, evaluatedAt time.Time, value *float64) error
UpdateState persists a rule's new evaluation state after one evaluator pass. The only method that writes Pending/Firing/ LastEvaluated/LastValue; SaveRule above never touches them.
func (*DB) UpsertCertExpiryObservation ¶
func (db *DB) UpsertCertExpiryObservation(ctx context.Context, o CertExpiryObservation) error
UpsertCertExpiryObservation creates or replaces the (rule_id, domain) row.
type DeliveryRecorder ¶
type DeliveryRecorder interface {
RecordNotificationDelivery(ctx context.Context, d NotificationDelivery) error
}
DeliveryRecorder is the narrow surface Engine and DeployDispatcher need to persist a delivery attempt. *DB satisfies it structurally.
type DeployDispatcher ¶
type DeployDispatcher struct {
// contains filtered or unexported fields
}
DeployDispatcher sends a deploy-outcome notification to every enabled DeployTarget scoped to one app. Built once in cmd/levelrail/main.go (mirroring how alertingNewNotifier is built once and shared by alerting.Engine) and shared by reference into internal/webhook and internal/api, the three real deploy-trigger call sites this task's own task description names.
func NewDeployDispatcher ¶
func NewDeployDispatcher(db *DB, client *http.Client, sender email.Sender, logger *slog.Logger) *DeployDispatcher
NewDeployDispatcher builds a DeployDispatcher. client and logger default if nil; sender may be nil (an email-kind target then fails with a clear "not configured" error).
func (*DeployDispatcher) Dispatch ¶
func (d *DeployDispatcher) Dispatch(ctx context.Context, resourceID string, ev DeployOutcome)
Dispatch sends ev to every enabled DeployTarget scoped to resourceIDForApp(ev.AppName)'s resource ID (resourceID, computed by the caller: this package has no dependency on internal/api's own resourceIDForApp helper, matching how it already has none on that package for alert rules). Must only be called once a deploy attempt has reached a terminal status (succeeded or failed), never for "running": there is nothing sensible to notify about an attempt that is still in progress, and none of the three real call sites (internal/webhook's beginDeployAttempt finish closure, internal/api/deploys.go's recordPlainDeployAttempt, internal/api/builds.go's beginBuildDeployAttempt finish closure) ever call this before FinishDeployAttempt has already persisted a terminal status.
A lookup failure or an individual target's send failure is logged, never returned: the deploy attempt this notification describes has already finished and already been persisted by the time Dispatch runs, so a notification failing to send must never be surfaced as if the deploy itself had failed, the identical "must never block the real operation" reasoning Engine.dispatch's own doc comment gives one layer up for alert rules.
func (*DeployDispatcher) SendTest ¶
func (d *DeployDispatcher) SendTest(ctx context.Context, kind NotifyKind, notifyURL string) error
SendTest fires a real test notification through kind/notifyURL, reusing this dispatcher's own HTTP client and email sender so a passing test predicts a real deploy notification will send the same way.
type DeployOutcome ¶
type DeployOutcome struct {
// AppName is the deploy's service name (store.DeployAttempt.ServiceName),
// used both in the message text and to look up which DeployTarget
// rows apply (resourceIDForApp(AppName) is computed by the caller,
// internal/api and internal/webhook, the same way it already is for
// alert rules; this package only ever sees the resulting resource ID).
AppName string
// Image is the tag this attempt deployed or tried to deploy
// (store.DeployAttempt.Image).
Image string
// Succeeded is store.DeployAttempt.Status == succeeded. Never called
// for the non-terminal "running" status: see Dispatch's own doc
// comment.
Succeeded bool
// Error is store.DeployAttempt.Error: only meaningful, and only ever
// non-empty, when !Succeeded.
Error string
}
DeployOutcome is what Dispatch sends: one finished deploy attempt's terminal outcome, everything summaryDeployText and deployGenericPayload need to describe it. Deliberately not alerting.Event: see this file's own package-level doc comment for why forcing this through Event/Rule would be the wrong fit.
type DeployTarget ¶
type DeployTarget struct {
ID string
ResourceID string
// ChannelID attaches an already-connected NotificationChannel; empty
// for legacy rows, which use NotifyURL/NotifyKind below directly.
ChannelID string
NotifyURL string
NotifyKind NotifyKind
Enabled bool
}
DeployTarget is a notify destination for deploy-outcome events, scoped to one app (ResourceID, matching Rule.ResourceID's own "service:<name>" convention) via internal/api's own resourceIDForApp. Deliberately much narrower than Rule: no metric/comparator/threshold, no restart-count/window, no evaluation state (pending_since/firing/firing_since/last_evaluated_at/last_value), because none of that describes anything about "where to send a deploy's outcome."
type DomainCheckSource ¶
type DomainCheckSource interface {
CheckDomainStatus(ctx context.Context, domain string) (status string, err error)
}
DomainCheckSource is the narrow surface EvaluateDomainHealth needs: the same DNS check GET /api/v1/apps/{name}/domains/{domain}/check itself runs (internal/api's runDomainCheck), reused through this interface rather than reimplemented here, so the dashboard's "Check now" button and a domain_health rule can never silently disagree on what "connected" means. *api.Router satisfies this via CheckDomainStatus.
type Engine ¶
type Engine struct {
// contains filtered or unexported fields
}
Engine evaluates every enabled rule on an interval, persists each rule's updated state, and notifies only on a firing/resolved transition, never on every tick a rule stays in the same state (the same reasoning already documented on Event.Resolved: repeated identical notifications train an operator to ignore the channel).
func NewEngine ¶
func NewEngine(rules RuleStore, metrics MetricsSource, logs LogsSource, tracker *RestartTracker, certs CertSource, scheduledTasks ScheduledTaskSource, certExpiryWarningWindow, certRenewalStalledThreshold time.Duration, nodes NodeSource, patchStatusThreshold, nodeDiskSpaceThreshold float64, nodeServices NodeServiceSource, nodeCPUThreshold, nodeMemoryThreshold float64, domainApps AppDomainSource, domainChecker DomainCheckSource, domainHealthCheckInterval time.Duration, newNotifier func(Rule) Notifier, logger *slog.Logger) *Engine
NewEngine builds an Engine. newNotifier defaults to a Notifier with no email capability configured if nil; a real caller passes a closure capturing an email.Sender instead. certs may be nil if no cert storage is configured; a kind=cert_expiry rule then logs a warning and is skipped each tick rather than evaluated. nodes and scheduledTasks may likewise be nil, in which case a kind=patch_status or kind=scheduled_task_failure rule is skipped the same way. certExpiryWarningWindow and certRenewalStalledThreshold fall back to DefaultCertExpiryWarningWindow/DefaultCertRenewalStalledThreshold when passed as 0; patchStatusThreshold falls back to DefaultPatchStatusThreshold, nodeDiskSpaceThreshold falls back to DefaultNodeDiskSpaceThresholdPercent, and nodeCPUThreshold/ nodeMemoryThreshold fall back to DefaultNodeCPUThresholdPercent/ DefaultNodeMemoryThresholdBytes, all the same way. nodeServices may be nil, in which case a kind=node_resource_usage rule is skipped the same way a kind=patch_status rule is when nodes is nil. domainApps and domainChecker may likewise be nil, in which case a kind=domain_health rule is skipped the same way; domainHealthCheckInterval falls back to DefaultDomainHealthCheckInterval when passed as 0.
func (*Engine) Run ¶
Run calls Tick on interval until ctx is done, matching the shape of every other periodic loop in this codebase (telemetry.Collector.Run, cmd/levelrail's retention sweeps).
func (*Engine) Tick ¶
Tick evaluates every enabled rule once. Errors from individual rules (a metrics query failing, a notification failing to send) are collected and joined, never stopping evaluation of the remaining rules: the same "one broken resource must not block the rest" principle reconcile.Engine.ReconcileAll already applies to controllers, applied here to alert rules.
type Event ¶
type Event struct {
Rule Rule
// Resolved is true when this event is "the rule stopped firing,"
// false when it's "the rule started firing." Sending a resolved
// notification (not just a firing one) is deliberate: an operator
// who only ever hears about problems starting, never ending, learns
// to distrust the channel or mute it, exactly the alert-fatigue
// failure mode a useful alerting feature has to avoid.
Resolved bool
// LogLines is populated only for a firing (not resolved) crashloop
// event: the last up-to-200 lines of the failing container's logs,
// per TASKS.md 2.7's literal requirement. Nil for threshold rules
// and for resolved events.
LogLines []string
// CertNotices is populated only for a firing (not resolved)
// cert_expiry event: one line per non-healthy certificate, from
// EvaluateCertExpiry, flagging a stalled-looking renewal specially.
// Nil for every other rule kind and for resolved events.
CertNotices []string
// PatchNotices is populated only for a firing (not resolved)
// patch_status event: one line per node over its security-patch
// threshold, from EvaluatePatchStatus. Nil for every other rule kind
// and for resolved events.
PatchNotices []string
// DiskSpaceNotices is populated only for a firing (not resolved)
// node_disk_space event: one line per node over its disk-usage
// threshold, from EvaluateNodeDiskSpace. Nil for every other rule
// kind and for resolved events.
DiskSpaceNotices []string
// ResourceUsageNotices is populated only for a firing (not resolved)
// node_resource_usage event: one line per node over its CPU and/or
// memory threshold, from EvaluateNodeResourceUsage. Nil for every
// other rule kind and for resolved events.
ResourceUsageNotices []string
// TaskFailureNotice is populated only for a firing (not resolved)
// scheduled_task_failure event: the failing task's command,
// consecutive-failure count, and last status, from
// EvaluateScheduledTaskFailure. Empty for every other rule kind and
// for resolved events.
TaskFailureNotice string
// DomainHealthNotices is populated only for a firing (not resolved)
// domain_health event: one line per unhealthy domain on this rule's
// own app, from EvaluateDomainHealth. Nil for every other rule kind
// and for resolved events.
DomainHealthNotices []string
}
Event is what a firing (or resolved) rule hands to a Notifier: enough context to write a useful message without the notifier needing to go query anything itself.
type EventSource ¶
EventSource is the narrow Docker surface RestartTracker needs. *docker.Client satisfies this structurally (it already implements Runtime, which embeds Events).
type Kind ¶
type Kind string
Kind distinguishes what a Rule evaluates.
const ( KindThreshold Kind = "threshold" KindCrashloop Kind = "crashloop" KindCertExpiry Kind = "cert_expiry" KindPatchStatus Kind = "patch_status" KindScheduledTaskFailure Kind = "scheduled_task_failure" KindNodeDiskSpace Kind = "node_disk_space" KindNodeResourceUsage Kind = "node_resource_usage" KindDomainHealth Kind = "domain_health" )
The eight rule kinds this package evaluates; see Rule's own doc comment for which fields each uses. KindCertExpiry, KindPatchStatus, KindNodeDiskSpace, and KindNodeResourceUsage use none of Rule's threshold/crashloop fields: they watch every certificate (cert_expiry.go), every node's patch status (patch_status.go), every node's disk usage (disk_space.go), or every node's CPU/memory usage (node_resource_usage.go) platform-wide, not a single Metric or RestartWindow, so ResourceID on any of the four is only ever a display label, not something their evaluator filters by. KindDomainHealth is app-scoped like KindThreshold/KindCrashloop (ResourceID picks out a real app), but watches every domain currently configured on that app rather than a single Metric; see domain_health.go.
type LogsSource ¶
type LogsSource interface {
QueryLogs(ctx context.Context, resourceID string, from, to time.Time, query string) ([]telemetry.LogEntry, error)
}
LogsSource is the narrow surface Engine needs to attach log lines to a firing crashloop event. *telemetry.Federator satisfies this structurally, the same as MetricsSource in evaluate.go.
type MetricsSource ¶
type MetricsSource interface {
QueryMetrics(ctx context.Context, resourceID, metric string, from, to time.Time) ([]telemetry.Sample, error)
}
MetricsSource is the narrow surface a threshold evaluation needs. *telemetry.Federator satisfies this structurally; a hand-written fake in tests avoids needing a real store for pure evaluation-logic tests.
type NodeAlertState ¶
type NodeAlertState string
NodeAlertState is one node-scoped alert kind's live status for one specific node, computed on demand by CheckNodeAlertStatus rather than read from a rule's stored, all-nodes-aggregated LastValue.
const ( NodeAlertOK NodeAlertState = "ok" NodeAlertFiring NodeAlertState = "firing" NodeAlertUnknown NodeAlertState = "unknown" )
The three states CheckNodeAlertStatus can report per kind. NodeAlertUnknown is distinct from NodeAlertOK on purpose: a node with no recent sample must never be reported as healthy, the same "don't silently confirm what you can't actually see" stance every evaluator in this package already takes on a per-node basis.
type NodeAlertStatus ¶
type NodeAlertStatus struct {
PatchStatus NodeAlertState
NodeDiskSpace NodeAlertState
NodeResourceUsage NodeAlertState
}
NodeAlertStatus is one node's live status across the three node-scoped, platform-wide alert kinds.
func CheckNodeAlertStatus ¶
func CheckNodeAlertStatus(ctx context.Context, node store.Node, services NodeServiceSource, metrics MetricsSource, patchThreshold, diskThresholdPercent, cpuThresholdPercent, memoryThresholdBytes float64, now time.Time, logger *slog.Logger) NodeAlertStatus
CheckNodeAlertStatus live-evaluates patch_status, node_disk_space, and node_resource_usage for node, right now, rather than waiting for Engine's next tick or reading a rule's stored aggregate LastValue (which only ever holds the worst value seen across every node, not which node it came from). It calls each evaluator's own per-node check helper directly so this can never drift from what a real rule would decide. A threshold of 0 falls back to that kind's own default, the same convention EvaluatePatchStatus/EvaluateNodeDiskSpace/ EvaluateNodeResourceUsage already use.
type NodeServiceSource ¶
type NodeServiceSource interface {
ListDesiredServicesByNode(ctx context.Context, nodeID string) ([]store.DesiredService, error)
}
NodeServiceSource is the narrow node-placement surface EvaluateNodeResourceUsage needs alongside NodeSource: which services are placed on a node, the same lookup internal/api's handleQueryNodeMetrics already uses (queryPlacedServiceSamples) to sum per-container metrics into a node-level total. *store.DB satisfies this structurally.
type NodeSource ¶
NodeSource is the narrow node-listing surface EvaluatePatchStatus needs. *store.DB satisfies this structurally, the same as CertSource above.
type NotificationChannel ¶
type NotificationChannel struct {
ID string
Name string
Kind NotifyKind
NotifyURL string
Enabled bool
CreatedAt string
UpdatedAt string
}
NotificationChannel is a global, connect-once notify destination (migrations/0003_notification_channels.sql's own doc comment), reused across apps' DeployTarget rows by ID instead of a fresh URL per app.
type NotificationDelivery ¶
type NotificationDelivery struct {
ID string
ChannelID string
Trigger string
Success bool
Error string
CreatedAt string
}
NotificationDelivery is one recorded attempt to send through a NotificationChannel. Every real send path (deploy-outcome dispatch, alert-rule dispatch, and the existing test-send routes) records one of these via recordDelivery below, so a channel's delivery history reflects real send attempts, not just test-button clicks.
type Notifier ¶
Notifier sends one Event somewhere. Every notify* function below satisfies this via notifyFunc, so the dispatch table in Dispatch stays a plain map, no interface boilerplate per channel.
func NewNotifier ¶
NewNotifier builds the right Notifier for r.NotifyKind. An unknown or empty NotifyKind falls back to NotifyGeneric rather than erroring, so a typo'd notify_kind still notifies someone, diagnosable from the payload shape. sender may be nil, in which case an email-kind rule fails with a clear "not configured" error.
type NotifyKind ¶
type NotifyKind string
NotifyKind selects the notification payload shape (notify.go).
const ( NotifyGeneric NotifyKind = "generic" NotifySlack NotifyKind = "slack" NotifyDiscord NotifyKind = "discord" NotifyTelegram NotifyKind = "telegram" NotifyEmail NotifyKind = "email" NotifyPushover NotifyKind = "pushover" NotifyPagerDuty NotifyKind = "pagerduty" NotifyTeams NotifyKind = "teams" NotifyResend NotifyKind = "resend" NotifyNtfy NotifyKind = "ntfy" NotifyGotify NotifyKind = "gotify" NotifyMattermost NotifyKind = "mattermost" NotifyLark NotifyKind = "lark" )
The thirteen payload shapes NewNotifier knows how to build; an unknown or empty NotifyKind falls back to NotifyGeneric. NotifyEmail is the one exception to "NotifyURL is a webhook URL": see emailNotifier's doc comment in notify.go. NotifyPagerDuty is another: NotifyURL there holds a routing key, not a URL, per notifyPagerDuty's own comment. NotifyResend is a third: it needs both an API key and a destination address, so NotifyURL there packs both as query parameters against a fixed endpoint, the same convention NotifyPushover already uses for its own two credentials; see parseResendCreds in notify.go.
type RestartTracker ¶
type RestartTracker struct {
// contains filtered or unexported fields
}
RestartTracker watches Docker's event stream and records each "start" event as a restart for whichever service owns that container, keyed the same "service:<name>" way every other telemetry identifier in this codebase is.
Counts "start" events, not "die" events: a container dying once is an ordinary crash, what makes it a crashloop is the reconciler repeatedly restarting the same container afterward (containers never carry Docker's own restart policy, the reconciler is the sole authority on bringing a dead container back, so every restart is visible as this process re-issuing Start on the same container ID). The very first start observed for a given container name is never counted as a restart of anything: a fresh deploy produces a brand-new container name (internal/reconcile/application's deterministic per-image naming), so its first start is ordinary startup, not a restart, without needing to special-case "was this a deploy or a crash" any other way.
func NewRestartTracker ¶
func NewRestartTracker() *RestartTracker
NewRestartTracker builds an empty RestartTracker.
func (*RestartTracker) CountSince ¶
func (t *RestartTracker) CountSince(resourceID string, since time.Time) int
CountSince returns how many restarts resourceID has had strictly after since. Safe for concurrent use.
func (*RestartTracker) Observe ¶
func (t *RestartTracker) Observe(resourceID, containerName string, at time.Time)
Observe records one "start" event for containerName, owned by resourceID, at time at. Safe for concurrent use.
func (*RestartTracker) Prune ¶
func (t *RestartTracker) Prune(olderThan time.Time)
Prune drops restart records older than olderThan, so this map doesn't grow without bound over a long-running process. Call periodically (Run does this once per resync), not per-evaluation.
func (*RestartTracker) Run ¶
func (t *RestartTracker) Run(ctx context.Context, source EventSource, services ServiceLister, resync time.Duration, logger *slog.Logger) error
Run consumes source's event stream until ctx is done, resolving each "start" event's container name against a service list refreshed every resync tick (services are created/removed over time; a container belonging to a brand-new service must resolve correctly without a process restart, the same level-triggered "re-derive, don't cache forever" principle every reconcile.Controller already follows).
type Rule ¶
type Rule struct {
ID string
Name string
Kind Kind
ResourceID string
// Threshold-kind fields.
Metric string
Comparator Comparator
Threshold float64
ForDuration time.Duration
// Crashloop-kind fields. RestartCountThreshold is also reused, with
// the same "N events triggers firing" meaning, by a
// KindScheduledTaskFailure rule: there it counts consecutive failed
// runs (store.ScheduledTask.ConsecutiveFailures) rather than restarts
// within RestartWindow, which that kind leaves unused.
RestartCountThreshold int
RestartWindow time.Duration
// ScheduledTaskID is the KindScheduledTaskFailure-only field: which
// of ResourceID's app's scheduled tasks this rule watches. Empty for
// every other kind.
ScheduledTaskID string
// ChannelID attaches an already-connected NotificationChannel; empty
// for legacy rules, which use NotifyURL/NotifyKind below directly.
ChannelID string
NotifyURL string
NotifyKind NotifyKind
// Enabled reflects both this rule's own flag and, once resolved by a
// read (GetRule/ListRules*), its attached channel's: a disabled
// channel silences the rule too.
Enabled bool
// Evaluation state, read-only from a caller's perspective: only the
// evaluator (evaluate.go, crashloop.go) writes these, via
// UpdateState below.
PendingSince *time.Time
Firing bool
FiringSince *time.Time
LastEvaluatedAt *time.Time
LastValue *float64
}
Rule is one alert rule: either a threshold check (Kind == KindThreshold, using Metric/Comparator/Threshold/ForDuration) or a crashloop check (Kind == KindCrashloop, using RestartCountThreshold/RestartWindow), plus its own current evaluation state. See migrations/0001_alert_rules.sql for why the two kinds share one table instead of two.
func EvaluateCertExpiry ¶
func EvaluateCertExpiry(ctx context.Context, certs CertSource, snapshots CertExpiryObservationStore, r Rule, warningWindow, stalledThreshold time.Duration, now time.Time, logger *slog.Logger) (Rule, []string, error)
EvaluateCertExpiry runs one KindCertExpiry rule against every certificate currently in certs and returns its updated evaluation state plus, when firing, one human-readable notice line per non-healthy certificate for Engine to attach to the outgoing Event.
The rule fires (satisfied, in advanceState terms) the instant any certificate is "expiring_soon" or "expired", with no ForDuration debounce: unlike a noisy metric sample, expiry status only moves monotonically with real time, so there is nothing to debounce. LastValue is the number of days until the earliest-expiring certificate (negative once expired), across every certificate, not just the non-healthy ones, so an operator can see "how close" even while healthy.
Per non-healthy domain, snapshots records the NotAfter it had when it first entered this expiry episode (EpisodeNotAfter/EpisodeStartedAt). If a later tick finds the same domain still non-healthy with an unchanged NotAfter for at least stalledThreshold, that domain's notice is marked as a renewal that appears stalled rather than a plain warning: Caddy's own renewal should have already succeeded well before the warning window even opened (DefaultCertRenewalStalledThreshold's own doc comment), so a stuck NotAfter this deep into the window is a stronger, more actionable signal than "getting close to expiry."
func EvaluateCrashloop ¶
func EvaluateCrashloop(tracker *RestartTracker, r Rule, now time.Time) Rule
EvaluateCrashloop runs one KindCrashloop rule against tracker and returns its updated evaluation state, the crashloop equivalent of EvaluateThreshold, sharing the same pending/firing debounce logic via advanceState. Passes forDuration=0: a crashloop rule fires the instant its restart count crosses the threshold, with no additional debounce layered on top, because RestartWindow itself already is the debounce (requiring N restarts within a real time window is already "sustained," unlike a single noisy metric sample).
func EvaluateDomainHealth ¶
func EvaluateDomainHealth(ctx context.Context, apps AppDomainSource, checker DomainCheckSource, r Rule, now time.Time, logger *slog.Logger) (Rule, []string, error)
EvaluateDomainHealth runs one KindDomainHealth rule against every domain currently configured on the app it's scoped to (ResourceID) and returns its updated evaluation state plus, when firing, one human-readable notice line per unhealthy domain, for Engine to attach to the outgoing Event.
Unlike EvaluateCertExpiry/EvaluatePatchStatus/EvaluateNodeDiskSpace (platform-wide, ResourceID only a display label), a domain_health rule's ResourceID is real: it picks out exactly which app's own domains this rule checks, the same app-scoped shape EvaluateThreshold/ EvaluateCrashloop use. An app with no domains configured, or one that has since been deleted, is treated like "no recent data": neither confirms nor denies.
r.ForDuration optionally debounces a single unhealthy check the same way EvaluateThreshold's own ForDuration does, since a real DNS lookup can blip; zero (the default) fires the instant any domain is unhealthy, matching every other non-threshold kind's own default.
func EvaluateNodeDiskSpace ¶
func EvaluateNodeDiskSpace(ctx context.Context, nodes NodeSource, metrics MetricsSource, r Rule, thresholdPercent float64, now time.Time, logger *slog.Logger) (Rule, []string, error)
EvaluateNodeDiskSpace runs one KindNodeDiskSpace rule against every node in nodes and returns its updated evaluation state plus, when firing, one human-readable notice line per node whose latest used-disk percentage meets or exceeds thresholdPercent, for Engine to attach to the outgoing Event.
Firing follows EvaluatePatchStatus's shape, not EvaluateThreshold's: the instant any node is over threshold, with no ForDuration debounce. LastValue is the highest used-disk percentage seen across every node with a recent sample, not just the ones over threshold, matching EvaluatePatchStatus's own LastValue convention.
A node missing either a disk_used_bytes or disk_total_bytes sample inside diskSpaceLookback (collector hasn't run yet), or reporting a total of zero or less, is skipped, neither confirming nor denying the condition. A node whose metrics query fails is logged and skipped too, the same "one broken resource must not block the rest" stance EvaluatePatchStatus already takes.
func EvaluateNodeResourceUsage ¶
func EvaluateNodeResourceUsage(ctx context.Context, nodes NodeSource, services NodeServiceSource, metrics MetricsSource, r Rule, cpuThresholdPercent, memoryThresholdBytes float64, now time.Time, logger *slog.Logger) (Rule, []string, error)
EvaluateNodeResourceUsage runs one KindNodeResourceUsage rule against every node in nodes and returns its updated evaluation state plus, when firing, one human-readable notice line per node whose summed CPU and/or memory usage meets or exceeds its threshold, for Engine to attach to the outgoing Event.
There is no real host-level CPU/memory collector in this codebase (unlike disk, telemetry has no /proc-based host reading): what exists is cpu_percent/memory_usage_bytes sampled per container (telemetry.Collector) and already summed across a node's placed services by internal/api's own node-metrics endpoint (nodeSummableMetrics). This evaluator reuses that exact same signal rather than inventing a new collector, taking each placed service's latest sample (not a time-series sum) since alerting only needs "right now," not a chart.
CPU and memory are evaluated together as one rule, not two, because they are the same "is this node under load" question from an operator's perspective, but they use genuinely different-shaped thresholds: CPU is compared as a percent (cpuThresholdPercent, the metric's own natural unit), memory as an absolute byte count (memoryThresholdBytes), since no honest node-capacity percentage exists for memory (see DefaultNodeMemoryThresholdBytes). LastValue only ever holds the highest summed CPU percent seen across nodes, matching EvaluateNodeDiskSpace's own "one representative number" convention; memory readings, having no comparable unit to take a single max across with CPU, are surfaced only in the per-node notice text.
Firing follows EvaluateNodeDiskSpace's shape: the instant either signal is over its threshold on any node, with no ForDuration debounce. A node with nothing placed on it, or whose placed services have no recent sample for either metric, is skipped, neither confirming nor denying the condition. A service whose own metrics query fails is logged and skipped, the same "one broken resource must not block the rest" stance EvaluateNodeDiskSpace already takes on a node.
func EvaluatePatchStatus ¶
func EvaluatePatchStatus(ctx context.Context, nodes NodeSource, metrics MetricsSource, r Rule, threshold float64, now time.Time, logger *slog.Logger) (Rule, []string, error)
EvaluatePatchStatus runs one KindPatchStatus rule against every node in nodes and returns its updated evaluation state plus, when firing, one human-readable notice line per node whose latest security-patch count meets or exceeds threshold, for Engine to attach to the outgoing Event.
Firing follows EvaluateCertExpiry's shape, not EvaluateThreshold's: the instant any node is over threshold, with no ForDuration debounce. HostPatchCollector already only samples once an hour, so there is nothing left for a per-tick debounce to smooth out. LastValue is the highest security-patch count seen across every node with a recent sample, not just the ones over threshold, so an operator can see "how close" the fleet is even while every node is healthy, matching EvaluateCertExpiry's own LastValue convention.
A node with no sample inside patchStatusLookback (no supported package manager, or the collector hasn't run yet) is skipped, neither confirming nor denying the condition, the same "no recent data" stance EvaluateThreshold takes. A node whose own metrics query fails is logged and skipped too, rather than failing the whole rule: the same "one broken resource must not block the rest" stance ListCertificates takes on a single malformed certificate.
func EvaluateScheduledTaskFailure ¶
func EvaluateScheduledTaskFailure(ctx context.Context, tasks ScheduledTaskSource, r Rule, now time.Time) (Rule, string, error)
EvaluateScheduledTaskFailure runs one KindScheduledTaskFailure rule against r.ScheduledTaskID's current ConsecutiveFailures count and returns its updated evaluation state plus, when firing, a human-readable notice line for Engine to attach to the outgoing Event.
Polls the task's own persisted counter rather than tracking anything itself, the same "read current state, no local history" shape EvaluateCertExpiry already uses: the counter is maintained by store.RecordScheduledTaskRun at the moment a run actually happens, decoupled from how often Engine.Tick itself runs.
Fires the instant ConsecutiveFailures crosses RestartCountThreshold, with no ForDuration debounce: requiring N consecutive failures is already the debounce, the same reasoning EvaluateCrashloop's own doc comment gives for RestartWindow.
func EvaluateThreshold ¶
func EvaluateThreshold(ctx context.Context, source MetricsSource, r Rule, now time.Time) (Rule, error)
EvaluateThreshold runs one KindThreshold rule against source and returns its updated evaluation state, to be persisted via DB.UpdateState by the caller (engine.go). Pure with respect to persistence: this function never writes to the database itself, so its state-machine logic (the part actually worth testing precisely) is testable without a real DB.
Semantics: the rule's condition must hold continuously, checked on every evaluation tick, for at least ForDuration before it graduates from "pending" to "firing" (avoiding a single noisy sample flapping a brand-new alert into existence). Once firing, it stays firing until a tick observes the condition no longer holding, at which point it resets to not-pending/not-firing immediately: there's no separate "resolving" debounce, a single good sample is enough to consider it recovered, asymmetric on purpose since false-positive resolution (briefly saying "recovered" when it isn't) is a much smaller problem than false-positive firing (paging someone for a blip).
type RuleStore ¶
type RuleStore interface {
ListEnabledRules(ctx context.Context) ([]Rule, error)
UpdateState(ctx context.Context, id string, pendingSince, firingSince *time.Time, firing bool, evaluatedAt time.Time, value *float64) error
RecordNotificationDelivery(ctx context.Context, d NotificationDelivery) error
UpsertCertExpiryObservation(ctx context.Context, o CertExpiryObservation) error
ListCertExpiryObservations(ctx context.Context, ruleID string) ([]CertExpiryObservation, error)
}
RuleStore is the narrow store surface Engine needs: list what to evaluate, persist the result. *DB satisfies this structurally. The two CertExpiry methods are what a kind=cert_expiry rule uses to remember its per-domain state across ticks (EvaluateCertExpiry, cert_expiry.go); every other rule kind never calls them.
type ScheduledTaskSource ¶
type ScheduledTaskSource interface {
GetScheduledTask(ctx context.Context, id string) (store.ScheduledTask, error)
}
ScheduledTaskSource is the narrow store surface a scheduled-task-failure evaluation needs. *store.DB satisfies this structurally.
type ServiceLister ¶
type ServiceLister interface {
ListDesiredServices(ctx context.Context) ([]store.DesiredService, error)
}
ServiceLister is the narrow store surface RestartTracker needs to resolve a container name to the service that owns it. *store.DB satisfies this structurally.