alerting

package
v0.2.0-beta Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 12, 2026 License: Apache-2.0 Imports: 26 Imported by: 0

Documentation

Overview

Package alerting implements 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.

Index

Constants

View Source
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.

View Source
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.

View Source
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."

View Source
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.

View Source
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).

View Source
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.

View Source
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

View Source
var ErrDeployTargetNotFound = errors.New("alerting: deploy target not found")

ErrDeployTargetNotFound is returned by GetDeployTarget when no target has that ID.

View Source
var ErrNotificationChannelNotFound = errors.New("alerting: notification channel not found")

ErrNotificationChannelNotFound is returned by GetNotificationChannel and DeleteNotificationChannel when id doesn't match any row.

View Source
var ErrRuleNotFound = errors.New("alerting: rule not found")

ErrRuleNotFound is returned by GetRule when no rule has that ID.

Functions

func CertExpiryStatus

func CertExpiryStatus(notAfter, now time.Time, warningWindow time.Duration) string

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

func NewDeployTargetID() (string, error)

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

func NewNotificationChannelID() (string, error)

NewNotificationChannelID mirrors NewDeployTargetID's exact shape.

func NewNotificationDeliveryID

func NewNotificationDeliveryID() (string, error)

NewNotificationDeliveryID mirrors NewNotificationChannelID's exact shape.

func NewRuleID

func NewRuleID() (string, error)

NewRuleID generates a random, URL-safe rule identifier. Exported so internal/api can assign one when creating a rule from a request body that doesn't include one, matching how a rule's ID is never caller-chosen data (avoids a client picking a colliding or predictable ID).

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

type DB struct {
	*sql.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

func Open(ctx context.Context, path string) (*DB, error)

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

func (db *DB) DeleteDeployTarget(ctx context.Context, id string) error

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

func (db *DB) DeleteNotificationChannel(ctx context.Context, id string) error

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

func (db *DB) DeleteRule(ctx context.Context, id string) error

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

func (db *DB) GetDeployTarget(ctx context.Context, id string) (*DeployTarget, error)

GetDeployTarget returns the deploy target with this ID, or ErrDeployTargetNotFound.

func (*DB) GetNotificationChannel

func (db *DB) GetNotificationChannel(ctx context.Context, id string) (*NotificationChannel, error)

GetNotificationChannel returns the channel with this ID, or ErrNotificationChannelNotFound.

func (*DB) GetRule

func (db *DB) GetRule(ctx context.Context, id string) (*Rule, error)

GetRule returns the rule with this ID, or ErrRuleNotFound.

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

func (db *DB) ListEnabledRules(ctx context.Context) ([]Rule, error)

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) ListRules

func (db *DB) ListRules(ctx context.Context) ([]Rule, error)

ListRules returns every rule, ordered by name.

func (*DB) ListRulesForResource

func (db *DB) ListRulesForResource(ctx context.Context, resourceID string) ([]Rule, error)

ListRulesForResource returns every rule scoped to resourceID, ordered by name, regardless of enabled state. internal/api's alert-rule handlers 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

func (db *DB) SaveRule(ctx context.Context, r Rule) error

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

func (e *Engine) Run(ctx context.Context, interval time.Duration) error

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

func (e *Engine) Tick(ctx context.Context) error

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.
	// 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

type EventSource interface {
	Events(ctx context.Context) (<-chan docker.Event, <-chan error)
}

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

type NodeSource interface {
	ListNodes(ctx context.Context) ([]store.Node, error)
}

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

type Notifier interface {
	Notify(ctx context.Context, ev Event) error
}

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

func NewNotifier(client *http.Client, sender email.Sender, r Rule) Notifier

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"
	NotifyRocketChat NotifyKind = "rocketchat"
	NotifyOpsgenie   NotifyKind = "opsgenie"
	NotifyWebex      NotifyKind = "webex"
	NotifyGoogleChat NotifyKind = "googlechat"
)

The seventeen 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. NotifyOpsgenie is a fourth: it needs only an API key, packed the same way against its own fixed endpoint; see parseOpsgenieCreds 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.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL