Observability: alerting rules¶
Every .yml in this directory is a Prometheus rule file, except <name>_test.yml,
which is a promtool unit test for <name>.yml. The build holds both to a pinned
promtool: bazel test //:observability_rules_test runs promtool check rules on
every rule file and promtool test rules on every rule test, and it is part of the
unit tests CI runs. A file added here is picked up with no other edit. To run
promtool by hand with the same version: bazel run //bazel/promtool -- test rules
docs/observability/<name>_test.yml.
mesh-DNS (mesh-dns-alerts.yml)¶
Covers the aether-mesh-dns DaemonSet, which is on the critical path for every
managed pod's DNS (the CNI DNATs all UDP+TCP :53 to HOST_IP:18054).
Since #578 the resolver runs in its own process and answers from a snapshot file the node agent writes. That cross-process dependency is the reason most of these rules exist — and why the external prober alone is not sufficient.
| Alert | Severity | Catches |
|---|---|---|
MeshDNSSnapshotStale |
critical | agent stopped writing → daemon serves frozen records |
MeshDNSNoRecords |
critical | empty snapshot → every mesh name NXDOMAINs |
MeshDNSNoUpstreams |
critical | no forward upstream → all non-mesh DNS fails |
MeshDNSWatcherInactive |
warning | fsnotify watcher died → updates silently stop |
MeshDNSReloadFailing |
warning | corrupt/unreadable snapshot |
MeshDNSForwardConnsRecyclingOnError |
info | pooled upstream sockets retired on exchange errors (#674) — resolution still works, it just costs an extra dial |
MeshDNSResolutionFailing |
critical | external prober can't resolve (per path) |
MeshDNSMetricsAbsent |
critical | daemons down fleet-wide, or the OTLP path is broken |
Why staleness is the important one¶
If the agent dies, no fsnotify event ever fires, so the daemon keeps serving its last
known table indefinitely. Because ready is true, misses are answered as
authoritative NXDOMAINs, which clients negatively cache. New services never resolve;
re-IP'd services resolve to dead ClusterIPs.
The external prober stays 100% green through all of it — its long-lived target keeps
resolving from the stale snapshot. MeshDNSSnapshotStale is the only signal for a
failure that is silently wrong rather than loudly broken.
The agent re-stamps the snapshot every 60s (capture.MeshDNSHeartbeat) even when
records are unchanged, precisely so that age is meaningful on a quiet cluster.
snapshot_generation advances only on a real content change, so the two are
distinguishable.
Probe targets cover different paths¶
The mesh_dns prober tier carries two targets, separable by the target label:
*.aether.internal→ answered authoritatively from the snapshot*.svc.cluster.local→ forwarded to kube-dns
Both matter: the forward path carries the majority of a real workload's lookups but almost no organic traffic here, so without the second target a kube-dns or upstream-failover regression would be invisible.
Node agent CNI conflist chaining (agent-cni-alerts.yml)¶
Covers the node agent's presence in the node's active CNI conflist. Aether is a
chained plugin inside another CNI's conflist, so any competing writer that rewrites
that file from its own template silently unchains us — on Talos, kube-flannel's init
container cp -fs its ConfigMap template over 10-flannel.conflist on every flannel
pod recreation, which a bootstrap-manifest re-sync triggers. That was the 2026-08-29
fleet-wide ~2h outage (#645).
| Alert | Severity | Catches |
|---|---|---|
AetherCNIConflistUnchained |
critical | per node: entry gone and unrepairable, or the agent reporting nothing at all |
AetherCNIConflistReasserting |
warning | strips the agent self-healed — invisible to the gauge, visible only in the counter |
AetherCNIConflistMetricsAbsent |
critical | metric renamed, OTLP path broken, or the re-assert loop disabled fleet-wide |
Why absent() is not the per-node answer¶
The gauge is pushed by the agent, so a down agent produces no series rather than a
0. absent() over the bare selector is fleet-wide: it drops the node label and
fires only when every agent is silent, so one node's agent going away produces neither
the == 0 arm nor the absent() arm — vacuous for exactly the outage it would be
written for.
Per-node absence needs an inventory joined with unless. The inventory is
aether_mesh_dns_ready, from the separate mesh-dns DaemonSet (#583) carrying the
same node label — which is why it survives the agent outage the rule exists to catch.
Residual gap, stated plainly: a node where both the agent and mesh-dns are silent falls
out of the inventory and is covered only by the fleet-wide meta-rule.
Why the counter is a separate rule¶
The re-assert loop repairs in ~2.5s and the OTLP PeriodicReader exports every 60s, so
a healed strip is exported as 1 and never observed as 0. The gauge under-reports
by construction; aether_agent_cni_conflist_reasserts_total is the only evidence the
strip happened at all.
These expressions have promtool unit tests in the GitOps repo
(clusters/<cluster>/prometheus/rules_test.yaml, run by CI), including a node whose
chained series is omitted entirely — the case absent() misses.
Registrar snapshot divergence (registrar-alerts.yml)¶
Every registrar replica serves an endpoint snapshot listed from etcd at one store
revision, and the listing is a pure function of that revision. Two replicas at the same
aether_registrar_snapshot_revision must therefore report the same value of
aether_registrar_snapshot_content_hash (#1193, #1329).
| Alert | Severity | Catches |
|---|---|---|
AetherRegistrarSnapshotDiverged |
warning | two replicas at one revision serving different endpoint sets, with no write-behind intent pending |
The hash is the gauge's value¶
aether_registrar_snapshot_content_hash is the first 13 hex digits (52 bits) of the
content hash in the snapshot version, as an integer; a float64 holds it exactly. One
series per replica and no label that changes with the content, so a content change moves
the value and leaves nothing behind in Prometheus's lookback. The rule is then a plain
count of distinct values per (job, revision): no timestamp() filter, and no
dependence on the two metrics' timestamps (the file's comments say how they arrive).
52 bits is a divergence check between a few replicas (two different endpoint sets
collide with probability 2^-52), not an identifier.
One series per replica, and a replica that is gone¶
The rule compares replicas, so it needs a label that tells them apart. Since chart 2.5.3
the registrar sets service.instance.id to its pod name, which Prometheus stores as
instance (#1560); before it, a pipeline that promoted nothing per replica stored one
series for all of them and the rule could not fire.
A pod name changes at every roll, and a pushed series has no staleness marker: a
replaced registrar's last sample is returned by a bare selector for five minutes. The
rule therefore wraps each of its three selectors in last_over_time(...[150s]) and
reads a replica only while its newest sample is at most 150 seconds old: two and a half
of the registrar's 60-second exports, so one lost export does not drop a live replica,
and less than the rule's for: 3m, so one last sample of a replica that is gone is
never an alert. metric-labels.md, "A label that holds the pod
name", has the reasoning and what a roll did without the window.
Removed: aether_registrar_snapshot_content{content_hash}¶
The hash used to be a label on an info gauge. A superseded hash stayed visible for the 5-minute lookback because OTLP has no staleness marker, and a count over the bare selector read that as a divergence (#1322: 108 of 1,920 samples on a soak with none);
1328 filtered on timestamp() to cope. The value gauge replaced it in chart 2.4.12¶
and the registrar stopped exporting the labelled metric in chart 2.5.9. A rule or a
dashboard that still reads it returns no data and reports no error
(docs/runbook.md, "Chart 2.5.9", has the checks). The rule in this directory never
read it.
The rule has a promtool unit test beside it, registrar-alerts_test.yml: same revision
and same hash (quiet), two live replicas with different hashes (fires after 3m), both
replicas changing hash together (quiet at every step), a replica one revision behind
(quiet), a pending write-behind intent (quiet), no revision (quiet), and the roll cases:
a replaced replica's odd last hash (quiet), a replaced replica's last queue depth (a
real divergence is reported two minutes later, not four), an alert that ends with the
replica that was replaced and one that stays firing while the divergence lasts, and a
live replica that misses an export (still read).
etcd backend only: the kubernetes backend reports no revision, so the rule returns nothing there. The gauge itself is reported on both backends.
Node agent SAN-pin state (agent-pin-alerts.yml)¶
Whether the mesh clusters a node proxy dials check the server identity they are handed
(#832, #1424, #1425, #1481, #1482). docs/runbook.md, "The unpinned-cluster signal",
says what each reason means and what to do about it. Reported by the node agent (job
of the agent) and by the edge control plane.
| Alert | Severity | for: |
Catches |
|---|---|---|---|
AetherMeshClusterUnpinned |
critical | 3m | per node and reason: clusters published with TLS and without a server-identity pin (no_namespace_metadata, pin_not_rendered). The mTLS validation gap |
AetherMeshClusterPinPending |
warning | 5m | per node and reason: an agent still publishing clusters with no TLS at all five minutes on (trust_domain_unknown, tls_not_published): it never learned its trust domain or never got its SVID |
AetherProxyHoldsUnpinnedClusters |
warning | 15m | per node: the proxy has accepted more unpinned clusters than the agent now publishes, so it has not accepted the update that pinned or removed them. A rejected cluster stays counted at the version the proxy last accepted through later ACKs of other clusters (#1508) |
AetherProxyPinStateUnknown |
warning | 15m | per node: the proxy holds mesh clusters whose pin state the agent cannot determine, so the acknowledged gauge is not written and the rule above cannot fire there (#1509). It does not say the proxy holds an unpinned cluster or that it rejects updates: a proxy rejecting a cluster update across an agent restart gets there, and so does one that accepted a version the agent no longer had on record |
| Metric (as Prometheus stores it) | Type | Labels | Meaning |
|---|---|---|---|
aether_agent_snapshot_tls_clusters |
gauge | pin, reason |
mesh cluster entries in the agent's current snapshot that are meant to be mTLS: pin="pinned" (no reason), and pin="unpinned" once per reason. Written on every snapshot, zeros included |
aether_agent_xds_acked_tls_clusters |
gauge | pin, reason |
the same count for the clusters the proxy has accepted, kept cluster by cluster: each is counted at the version the proxy last acknowledged or stated when it opened its stream (#1508). An entry with no cluster in the snapshot is in it only while the proxy still holds that cluster from an earlier snapshot (it rejected the removal); one whose cluster was never published, or whose removal the proxy accepted, is not. Absent until a proxy answers a cluster response of this agent process, which a reconnecting proxy normally does within a second or so of an agent restart (#1483); not written while the proxy holds a mesh cluster this agent process has no pin class for: a version it never published, or a cluster it no longer publishes and has no record of |
aether_agent_xds_acked_tls_clusters_unknown |
gauge | none | how many mesh clusters the proxy holds whose pin state the agent cannot determine (#1509). Zero while the gauge above is written, the number of such clusters while it is not written for that reason, and absent, like it, until a proxy answers a cluster response of this agent process |
aether_agent_identity_cluster_unpinned_total |
counter | reason |
grows by the number of unpinned clusters on every snapshot that has any. Seeded at zero per reason. Before #1424 it had no reason label |
aether_agent_xds_nacks_total |
counter | aether_xds_type_url |
delta-xDS responses the proxy rejected. Seeded at zero for each of the six resource types the agent serves, and other (#1480) |
aether_agent_xds_ack_wait_failures_total |
counter | aether_xds_wait, aether_xds_reason |
ACK waits for a pod's listener that failed (present/absent by nack/timeout). Seeded at zero, four series (#1480) |
reason is a closed set of four: trust_domain_unknown, tls_not_published,
no_namespace_metadata, pin_not_rendered. No series carries a cluster name, so each
gauge is five series per agent and the counter four, whatever the size of the mesh; the
names are in the agent's WARN line.
Severity follows what is on the wire¶
The four reasons are two kinds of fact, and the rules split on that:
- Under
no_namespace_metadata(andpin_not_rendered, which no code path produces) the cluster is published with TLS and its handshake accepts any workload of the trust domain. That is an authentication gap being served: critical. - Under
trust_domain_unknownandtls_not_publishedthe agent publishes no TLS for those clusters. Nothing is authenticated wrongly; a peer's mesh inbound refuses the connection. It is the normal state of an agent for the moment before it has its identity, and a fault only when it lasts: warning, after a longerfor:.tls_not_publishedis also the reason of a TCP service with no namespace metadata whose floor cluster is not in the snapshot (the service is not captured; on the edge, no route references it). That one lasts with a healthy agent, and the warning is then about the registry data: the entry is the critical rule's the moment its floor is published.
tls_not_published exists since #1482. Before it, an entry whose endpoints carry no
namespace was no_namespace_metadata whether or not TLS was published, so a critical
rule on that reason would have fired for an agent that was merely waiting for its SVID.
An entry moves from tls_not_published to no_namespace_metadata in the snapshot that
first publishes TLS for it, so the critical rule's clock starts when the gap does. The
agent settles the reason against what each snapshot publishes, not only against its own
state: a snapshot able to publish TLS never leaves an entry under tls_not_published
unless that entry's floor cluster is absent from it.
Why for: 5m rides out an agent start¶
- Measured on a five-node test cluster: all five running agents had their SVID within
0.5 s of asking (
aether_agent_spire_wait_seconds, every observation in the lowest bucket). The agent exports every 60 s, so a normal start is shorter than one export and usually in no gauge sample at all. The counter still records it. - From the code: an agent still without an SVID after 2 minutes
(
--spire-wait-warn-after) reports NotReady. That is the agent's own bound on a healthy start. - 5 minutes is that bound, two more exports so the state is seen twice past it, and one for evaluation jitter.
AetherMeshClusterUnpinned has no start-up window to ride out (the same entries are
tls_not_published until TLS exists), so its 3 minutes are only "three exports in a row
are a state, one is a snapshot".
A mesh run without SPIRE never has a node SVID: every entry with no namespace
metadata stays tls_not_published, by design. Drop AetherMeshClusterPinPending there.
The divergence rule and an absent gauge¶
aether_agent_xds_acked_tls_clusters has no series until a proxy answers a cluster
response of that agent process: the ACK of one that added or removed a cluster, or the
answer to the first one of a stream. It is kept per cluster (#1508): an ACK moves the
clusters its response carried and no other, so a cluster the proxy rejected stays where
it was through later ACKs. Since #1483 an agent that restarts against a proxy
already in sync has its sample as soon as the proxy reconnects (the proxy states the
clusters it holds and acknowledges the agent's empty answer). The gauge is still absent
until a proxy has answered that agent process at all (a standby agent, a proxy that has
not connected since the agent started), and after a restart
for as long as the proxy rejects a cluster it holds an older version of (the new agent
process cannot count a version it never published, and writes nothing rather than a
count without it), or rejects the removal of a mesh cluster the agent no longer
publishes and has no record of: the runbook's "Published is not held" has them. Whenever
the gauge is absent because the agent cannot classify a cluster the proxy holds, those two
cases among them, aether_agent_xds_acked_tls_clusters_unknown is above zero, and
AetherProxyPinStateUnknown speaks for the node after 15 minutes (#1509). Once the gauge has
samples, a proxy stream that drops does not withdraw them: the last accepted state
stays exported until the proxy answers again, so a sample is not proof of a live xDS
connection.
AetherProxyHoldsUnpinnedClusters compares two vectors, and a comparison
returns nothing for a (job, node) that one side lacks, so that rule is silent there: it
cannot fire on absence, and it does not need absent() or or vector(0) (either
would make it fire there). It is one-directional on purpose: acknowledged below
published is an unpinned cluster not yet acknowledged, or an entry with no cluster in
the snapshot that the proxy does not hold (never published, or its removal accepted),
which the first rule already covers. Its residue is stated in the file: a late ACK from
the proxy generation that is leaving during a hot restart, for a cluster that does not
change afterwards.
Four more things to know. The figure in an annotation ({{ $value }}) is per job and
node, added up over every series stored for them: for two edge control-plane replicas
that share a node, which are kept apart by instance (their pod name, chart 2.5.3 and
later; see "Labels the rules need from your pipeline"), it counts a cluster once for
each replica that reports it (#1616). Every selector of the four rules is wrapped in
last_over_time(...[150s]), so a series is read only while its newest sample is at most
150 seconds old: an edge pod that was replaced, or an agent that stopped, leaves the
sums two and a half minutes after its last export and not five
(metric-labels.md, "A label that holds the pod name", has why
150). The gauges are pushed by the agent, so a down agent is no
series, not a zero (the same trap as the conflist gauge above): these rules are silent
for a node whose agent does not report, and AetherCNIConflistUnchained is the rule for
that. Anything that sums or compares the counter across an upgrade must not select on
reason: an agent from before #1424 exports the one label-less series, and one from
before #1482 has no tls_not_published series. And the rules have a promtool unit test
beside them, agent-pin-alerts_test.yml (an agent start, an agent stuck without its
SVID, the gap, an absent acknowledged gauge, an agent restart with the proxy in sync and
with the proxy rejecting, a proxy that keeps what the agent has pinned, also through
later ACKs of other clusters, a pin state the agent cannot determine, two edge replicas
on one node, a silent agent, and an edge pod that is replaced: its last sample alone,
an alert that ends with it, one that stays firing on its replacement, a live pod that
misses an export, and the limit of the window):
bazel test //:observability_rules_test
Rejected xDS updates (agent-xds-alerts.yml)¶
The agent, and the edge control plane, count every delta-xDS response their proxy
rejects in aether_agent_xds_nacks_total, by resource type, seeded at zero (#1480). A
rejection is always a defect of the agent that built the response.
| Alert | Severity | for: |
Catches |
|---|---|---|---|
AetherProxyRejectedListenerUpdate |
warning | 0m | per job and node: the proxy rejected a Listener response in the last hour. One rejection fires it |
Listener rejections have a rule of their own because of what the proxy does with one
(#1633): it applies the rest of the response, its removals and the listeners it could
build, and a listener kept that way is not in what the proxy states when it reconnects.
After an agent restart the agent does not know of it, and nothing removes it when its
pod goes. The agent does not hunt for such listeners; the rule makes the precondition
loud, and docs/runbook.md, "The proxy rejected a Listener update", has how to find the
listener and the recovery (fix the cause, then replace the proxy pod on that node).
Rejected Cluster responses are read by the pin rules above.
Three things to know, each a case in agent-xds-alerts_test.yml. The expression has two
arms: increase(...[1h]) > 0, and one for a series whose first sample is already above
zero (the proxy rejected the first response of an agent before the agent's first
export, on a node nothing reported from in the two hours before), where increase() is
0. The alert ends an hour after the last rejection whatever was done, because a
counter cannot tell that the proxy was replaced: it is an event, not a state. And it
misses one case and repeats in another: a restarted agent whose first export carries
exactly the count its predecessor ended on is one unbroken series and is not seen; and
the two agents of a surge roll, in a pipeline that keeps agents apart by node alone,
share one series for the overlap, so an old count next to the successor's zero reads as
an increase and the alert fires again with no new rejection.
The edge control plane's counter is one series per pod since chart 2.5.3 (instance).
The first of those two cases does not apply to it (a replacement rejected before its
first export is a new series and fires), an edge roll long after a rejection is quiet,
and a rejection before a pod's first export ends five minutes after that pod is
replaced instead of an hour after the rejection. This rule has no 150-second window:
it reports an event from a counter, and increase() over a series that ended still
counts what it counted.
Labels the rules need from your pipeline¶
Every rule here selects on node, job, or both, except the two fleet-wide absent()
meta-rules (MeshDNSMetricsAbsent, AetherCNIConflistMetricsAbsent), which select on
neither. The components do not set those labels: they set the resource attributes k8s.node.name and service.name, and the
metrics pipeline has to turn k8s.node.name into a label called node. Where it does
not, AetherCNIConflistUnchained cannot fire for a single node and the per-node rules
fire without naming one. metric-labels.md has the attributes
each component sets, what each rule file needs, what a missing promotion looks like, and
an example pipeline. Read it before installing the rules.
The registrar's, the controller's and the edge's replicas are told apart by instance,
which Prometheus makes from the service.instance.id the chart sets (2.5.3 and later).
That one needs nothing from the pipeline except that it is not dropped.
Installing¶
There is no Prometheus operator on the reference cluster (no PrometheusRule CRD) and the
Grafana install has no alerting sidecar — only dashboard and datasource sidecars.
So these rules are delivered through the Prometheus helm values:
# prometheus helm values
serverFiles:
alerting_rules.yml:
groups:
# contents of mesh-dns-alerts.yml
# contents of agent-cni-alerts.yml
# contents of registrar-alerts.yml
# contents of agent-pin-alerts.yml
# contents of agent-xds-alerts.yml
Take the rule files of the chart you run, and update them before or with the chart.
Chart 2.5.3 puts the pod name in the instance label of the registrar's, the
controller's and the edge's series, and the rule files of that release read only
samples of the last 150 seconds so that a replaced pod is not counted. Rule files from
an earlier release, left in place under chart 2.5.3, fire AetherMeshClusterUnpinned
and AetherRegistrarSnapshotDiverged falsely after a roll of the edge or the registrar
(one last sample of the old pod is read for five minutes). The new rule files are
correct against an older chart too, so install them first. docs/runbook.md, "Chart
2.5.3", has the order and what to check.
prometheus.yml's rule_files already lists /etc/config/alerting_rules.yml — the
wiring exists, the file was just empty — so populating that key is the only change needed
to make the rules evaluate.
Do not helm upgrade by hand: those values are reconciled by Flux (see below).
Alert delivery (Alertmanager -> Slack + GitHub issue)¶
This file is the source of truth for the rules; it is not where they are deployed from. The reference cluster is GitOps-managed by Flux, so nothing here is applied by hand — the rules and the delivery path both live in the platform's GitOps repository:
| what | where |
|---|---|
| alert rule groups | clusters/<cluster>/prometheus/values.yaml → serverFiles.alerting_rules.yml |
Alertmanager routing + github-slack receiver |
same file → alertmanager.config |
| Slack webhook URL | an encrypted Secret, mounted as a file via extraSecretMounts → global.slack_api_url_file (Alertmanager cannot interpolate env vars, and alertmanager.config renders into a plaintext ConfigMap) |
| GitHub receiver Deployment | clusters/<cluster>/alertmanager-github-receiver/ |
| GitHub token | an encrypted Secret in that directory |
One receiver, two legs. Slack is the pager: GitHub issues created with
your own PAT are self-authored, and GitHub does not notify you about your own actions —
issues alone reach nobody. The GitHub issue is the durable record: a firing alert
opens an issue on this repo labelled alert, and closes it on resolve. Issues are keyed
on GroupKey, and group_by: [alertname] makes that stable — so one issue per
condition listing every firing node, and a flapping alert reopens its issue rather
than opening a new one.
Things that are easy to get wrong, already handled there:
- There is no
Watchdogrule, on purpose. The stock always-firing dead-man's switch was removed together with its→ "null"route: nothing outside the cluster consumed the heartbeat, so it proved nothing and would file an immortal issue if it ever leaked to a real receiver. (If you re-introduce an always-firing rule, remove rule before route on teardown — the reverse ordering once briefly filed one.) A real dead-man's switch needs an external sink and is still an open item. measurementlab/alertmanager-github-receivercannot run on the reference cluster. It is the receiver everyone cites, but it is published amd64-only (neitherlatestnorv0.11is a multi-arch index) while every reference-cluster node is arm64. We useghcr.io/pfnet-research/alertmanager-to-github, which ships a genuine multi-arch index, pinned by digest.
Verify what is actually loaded:
kubectl get cm prometheus-server -n prometheus \
-o go-template='{{index .data "alerting_rules.yml"}}' | head
Rules appear under Alerts in the Prometheus UI, and ALERTS{alertstate="firing"}
becomes queryable via the Grafana Prometheus datasource.
Do the collector bump BEFORE enabling these¶
Prometheus here is OTLP-receive-only (all scrape configs disabled,
web.enable-otlp-receiver), so series arrive by push. Under memory pressure the
otel-collector sheds gauge exports -- during the 2026-07-25/26 soak one node
vanished from aether_mesh_dns_records and snapshot_age_seconds for a stretch while
its resolver was provably healthy.
Four of the seven rules key on exactly those gauges (MeshDNSNoRecords,
MeshDNSNoUpstreams, MeshDNSWatcherInactive, MeshDNSMetricsAbsent). With a shedding
collector a healthy node whose export was dropped is indistinguishable from a broken
one, and absent() fires on the telemetry gap rather than a resolver failure. Enabling
these before the collector has headroom trains everyone to ignore them within a week.
MeshDNSSnapshotStale and MeshDNSResolutionFailing are safer -- the latter is
counter-based, and counters self-heal across a refused export.
Prove each rule fires before trusting it¶
Break something deliberately and confirm the expected rule -- and only that rule --
fires. MeshDNSResolutionFailing most of all: its exclusion of http_error and
inclusion of generic timeout is reasoned (a hung resolver surfaces as a context
deadline, while http_error is the cross-node backend path) but has never been
observed firing.