Metric labels: what the metrics pipeline has to make¶
The alert rules in this directory, and the queries in ../runbook.md,
select on the labels node and job. Aether does not put those labels on its metrics.
It puts the facts on the OpenTelemetry resource each component exports with, and the
metrics pipeline between the components and the query engine turns resource attributes
into labels. This page says which attributes each component sets, which labels the
shipped rules need, and what you see when one is missing.
It covers the OTLP push path (chart value otel.endpoint in charts/aether,
telemetry.otlpEndpoint in charts/prober). If you scrape a component's
--metrics-bind-address endpoint instead, the labels are whatever your scrape
configuration attaches, and the same label names are needed.
What you must do¶
| The rules need | Make it from | Who makes it |
|---|---|---|
node |
resource attribute k8s.node.name |
your pipeline. Nothing makes it by default |
job |
resource attribute service.name |
Prometheus's OTLP ingestion maps service.name onto job by itself. Another backend may need it done |
| one label that differs per replica of the registrar, the controller and the edge control plane, under any name | resource attribute service.instance.id (the pod name; chart 2.5.3 and later) |
Prometheus's OTLP ingestion maps service.instance.id onto instance by itself. Another backend may need it done. Your pipeline must not drop it: see below |
Do not drop instance from the registrar's, the controller's and the edge's series.
A Collector processor that removes service.instance.id, a relabelling rule that drops
instance, or a remote-write stage that aggregates it away puts every replica of those
back into one series, and AetherRegistrarSnapshotDiverged can then never fire
(What you see when a label is missing). With a
chart before 2.5.3 the attribute is not set, and the per-replica label is your
pipeline's to make, from k8s.pod.name.
The label has to be called node. Promoting k8s.node.name under its translated name
(k8s_node_name) keeps the series apart but the rules do not select on it: see
What you see when a label is missing.
What each component sets¶
Every Go component builds its resource from a fixed service.name, its build version,
and the OTEL_RESOURCE_ATTRIBUTES environment variable the chart sets from the downward
API. The edge proxy builds its resource from the environment variable alone. The node
proxy builds its from the environment variable and then from its bootstrap, which names
the service (see below for why it is not in the variable).
| Component | service.name |
From the chart's OTEL_RESOURCE_ATTRIBUTES |
|---|---|---|
node agent (aether_agent_*) |
aether-agent |
k8s.node.name, k8s.pod.name, k8s.namespace.name |
mesh-dns (aether_mesh_dns_*) |
aether-mesh-dns |
k8s.node.name, k8s.pod.name, k8s.namespace.name |
proxy supervisor (aether_supervisor_*) |
aether-proxy-supervisor |
k8s.node.name, k8s.namespace.name, k8s.pod.name, k8s.pod.uid |
node proxy, Envoy stats (envoy_*, aether_requests_total) |
aether-proxy (chart 2.4.26 and later; none before) |
k8s.node.name, k8s.namespace.name, k8s.pod.name, k8s.pod.uid |
edge control plane (agent edge) |
aether-edge |
service.instance.id (the pod name; chart 2.5.3 and later), k8s.node.name, k8s.namespace.name, k8s.pod.name, k8s.pod.uid, k8s.deployment.name |
| edge proxy, Envoy stats | aether-edge-proxy |
the same (service.instance.id in every chart version) |
registrar (aether_registrar_*) |
aether-registrar |
service.instance.id (the pod name; chart 2.5.3 and later), k8s.pod.name, k8s.namespace.name |
| controller | aether-controller |
service.instance.id (the pod name; chart 2.5.3 and later), k8s.pod.name, k8s.namespace.name |
prober (aether_probe_*) |
aether-prober |
k8s.node.name, k8s.namespace.name, k8s.pod.name |
Where this comes from: the OTEL_RESOURCE_ATTRIBUTES entries in
charts/aether/templates/ and charts/prober/templates/daemonset.yaml; the resource
builders in common/telemetry/setup/setup.go (agent, edge control plane, registrar,
controller), agent/internal/meshdns/telemetry.go,
agent/internal/proxy/hotrestart/telemetry.go and prober/internal/prober/prober.go;
and the resource_detectors entries of the Envoy stats sink in
agent-proxy-configmap.yaml and edge-configmap.yaml.
Five things follow from the table.
- The registrar and the controller set no
k8s.node.name. They are Deployments, and the node is not what tells their replicas apart. Only the pod name does, which they set twice: asservice.instance.idand ask8s.pod.name. - The Deployments set
service.instance.id; the per-node DaemonSets do not. Since chart 2.5.3 the registrar, the controller and both containers of the edge set it to the pod name (#1560; the edge proxy always did). Prometheus stores it asinstance, so their replicas are separate series with no pipeline configuration. The node agent, mesh-dns, the proxy supervisor and the node proxy do not set it, on purpose: what tells two of those apart is the node, which keeps its value across a roll, where a pod name starts a new set of series per node at every roll. Without a promotednodelabel every instance of those writes the same label set. (mesh-dns also has a datapoint attribute of its own calledinstance, on its exit counter, which a resource-made label would collide with.) What a pod name in a label asks of a rule or a panel is under A label that holds the pod name. - The edge control plane's replicas are told apart by
instance, not bynode. The chart runs twoagent edgereplicas by default and spreads them across nodes only softly (edge.replicaCount,edge.nodeSpread), so two can share a node. Both report the pin gauges, and withjobandnodealone (a chart before 2.5.3, or a pipeline that dropsinstance) two co-located replicas write the same series. - The node proxy's Envoy stats set
service.name=aether-proxysince chart 2.4.26, so they arejob="aether-proxy"whereverservice.nameis mapped tojob(the table under What you must do). Before that they had noservice.nameand arrived without ajob; the edge proxy exports the same metric names withjob="aether-edge-proxy", and a query could only keep the two apart by negation ({job!="aether-edge-proxy"}, which still selects the node proxy's series from both chart versions). The name is in the proxy's bootstrap (aether-proxy-config), not in the container's environment, because the supervisor runs in the same container and exportsaether_supervisor_*from that environment asaether-proxy-supervisor. Do not addOTEL_SERVICE_NAMEto the proxy container: it would rename the supervisor's metrics. Aservice.namein the container'sOTEL_RESOURCE_ATTRIBUTESchanges neither name. - Only the
hostNetworkGo components sethost.name. The binary decides this, not the chart: the SDK's host detector reads the hostname, and a component keeps the attribute only where that hostname is a host's.
| Component | hostNetwork |
host.name |
|---|---|---|
| node agent | yes | the node's hostname |
| mesh-dns | yes | the node's hostname |
| proxy supervisor | yes | the node's hostname |
edge control plane (agent edge) |
no | not set (#1596) |
| registrar | no | not set (#1596) |
| controller | no | not set (#1596) |
| prober | no | not set (#1041) |
On the pod network the hostname is the pod name, which k8s.pod.name already holds for
all four, so leaving it out loses nothing. Until #1596 the registrar, the controller
and the edge control plane did set it, to their pod name. A pipeline that selected or
grouped their series by a label made from host.name uses k8s.pod.name instead.
Do not derive node from host.name ahead of k8s.node.name. A node's hostname need
not be its Kubernetes node name, and series from a release before #1596 (or from a
prober before #1041) hold a pod name there, which joins with nothing
(../configuration.md, the prober's "Metrics").
Labels that are not your pipeline's job: the prober's pod, tier, target and
result, and every reason, pin, result or aether_* label in the rules, are
datapoint attributes the component sets. They arrive as labels with no promotion. Metric
and label names are stored with dots turned into underscores, and a counter gains
_total.
What the shipped rules select on¶
| Rule file | Labels it needs | Where |
|---|---|---|
mesh-dns-alerts.yml |
node |
every per-node rule aggregates by (node). MeshDNSResolutionFailing aggregates the prober's counter by (node, target). MeshDNSMetricsAbsent is absent() over the bare metric and needs no label |
agent-cni-alerts.yml |
node, equal across two components |
AetherCNIConflistUnchained joins mesh-dns's aether_mesh_dns_ready against the agent's aether_agent_cni_conflist_chained on node. AetherCNIConflistReasserting sums by (node). AetherCNIConflistMetricsAbsent is absent() and needs no label |
agent-pin-alerts.yml |
job, node |
all four rules aggregate by (job, node, …), over the node agent's series and over each edge replica's (instance). Every selector reads samples of the last 150 s only |
agent-xds-alerts.yml |
job, node |
AetherProxyRejectedListenerUpdate aggregates by (job, node) |
registrar-alerts.yml |
job, and one series per replica |
count by (job, revision) over each replica's hash. The expression keeps every label (without ()), so the replica label may have any name: instance by default. Every selector reads samples of the last 150 s only |
The join in agent-cni-alerts.yml is why the agent and mesh-dns DaemonSets both stamp
k8s.node.name from spec.nodeName: the two node labels have to hold the same value.
This repository ships no recording rules and no dashboards. A grader or dashboard of
your own that groups per node or per component needs the same two labels; the queries in
../runbook.md are written with them.
What you see when a label is missing¶
The first three rows were run through promtool test rules against the rule files in
this directory, with two nodes or two replicas and one of them in the failing state.
| Pipeline does this | What happens |
|---|---|
promotes k8s.node.name as k8s_node_name, not node |
AetherCNIConflistUnchained does not fire for the unchained node. Both sides of its unless aggregate to one series with no node, and one healthy agent anywhere cancels the alert. It fires only when no agent in the fleet reports 1 |
| the same | MeshDNSNoRecords (and the other by (node) rules) still fire, as one fleet-wide alert with no node label. The summary reads "mesh-DNS is serving zero records on " and names no node |
gives the registrar replicas no label that differs (drops instance; or, with a chart before 2.5.3, promotes no k8s.pod.name) |
AetherRegistrarSnapshotDiverged can never fire. The replicas write one series, the rule counts distinct hashes across replica series, and one series is never more than one hash |
| promotes nothing per node at all | every agent (and every mesh-dns, supervisor and node proxy) writes the same label set. The prober does not collapse, because it sets its own pod label; its series only lose their node. One series then has one writer per node: a gauge holds whichever node exported last, and rate() or increase() over a counter reads the interleaved cumulative values as resets. The chart's comments record both from before the attributes existed: fabricated resets and connect failures on the node proxy's agent_xds counters, and Prometheus rejecting whole batches with "duplicate sample for timestamp" for two edge proxies |
leaves job out |
AetherRegistrarSnapshotDiverged still works for one registrar Deployment (it was run with no job). The pin rules sum the node agent's series together with the edge control plane's on a node that runs both, and their text has an empty {{ $labels.job }} |
The last row but one is the reason absent() cannot stand in for a missing promotion: a
collapsed series is present. The value is wrong, and the series is there.
Example pipeline¶
An example, not a supported configuration. It shows the shape of the two promotions for an OpenTelemetry Collector that forwards to a Prometheus OTLP receiver. It was not run as part of this repository's tests: check the processor and its statement syntax against the Collector version you run, and the result against the checks in the next section.
# OpenTelemetry Collector (contrib): metrics pipeline
processors:
transform/aether_labels:
error_mode: ignore
metric_statements:
- context: datapoint
statements:
# node: from k8s.node.name only. Never from host.name.
- set(attributes["node"], resource.attributes["k8s.node.name"])
where resource.attributes["k8s.node.name"] != nil
# Nothing for the Deployments (registrar, controller, edge): since
# chart 2.5.3 they set service.instance.id, which Prometheus stores
# as `instance`. Leave that attribute on the resource. With an older
# chart, make a per-replica label for those three here instead:
# - set(attributes["pod"], resource.attributes["k8s.pod.name"])
# where resource.attributes["service.name"] == "aether-registrar" ...
# The prober sets its own `pod`.
service:
pipelines:
metrics:
receivers: [otlp]
processors: [transform/aether_labels, batch]
exporters: [otlphttp/prometheus]
Prometheus's own OTLP setting does the promotion without a Collector, under the attribute's translated name:
# prometheus.yml
otlp:
promote_resource_attributes:
- k8s.node.name # stored as k8s_node_name
- k8s.pod.name # stored as k8s_pod_name
That keeps the series apart, which is the first half. The rules here say node, so with
this alone you also replace node with k8s_node_name in every rule file you install,
or you rename in a Collector as above.
Cardinality. A label that holds the pod name starts a new set of series every time
the pod is replaced, which is why the chart sets service.instance.id only on the three
Deployments and not on the per-node DaemonSets, where node is stable across a roll.
Do not add a pod label for the DaemonSets either. For the registrar, the controller
and the edge that is a handful of replicas, and the series of a replaced pod receive no
more samples.
A node label on the node proxy's per-cluster Envoy stats multiplies
those series by the number of nodes. The chart's comments
(agent-proxy-daemonset.yaml) describe a pipeline that promotes node only on the
proxy's two static clusters, agent_xds and otel_collector, which exist once per node
and collide without it, and leaves the per-service clusters collapsed fleet-wide. That
is a choice your pipeline makes. A per-node query over a per-service cluster stat needs
the label on those series too.
A label that holds the pod name¶
instance on the registrar, the controller and the edge is the pod name (and so is a
pod label your pipeline makes from k8s.pod.name). It keeps replicas apart, and it
also makes every roll of those Deployments end one set of series and start another. A
pushed series has no staleness marker: nothing tells the store that the old pod is
gone, so a bare selector returns its last sample for five minutes, beside the new
pod's. Anything that aggregates over replicas, or compares them, reads a pod that no
longer exists for those five minutes.
The rule files in this directory therefore read fresh samples only. Each selector in
agent-pin-alerts.yml and registrar-alerts.yml is wrapped:
last_over_time(aether_agent_snapshot_tls_clusters{pin="unpinned"}[150s])
which is the newest sample of each series, if it is at most 150 seconds old. Why that
number: these components export every 60 seconds (the OpenTelemetry SDK's periodic
reader at its default interval; common/telemetry/setup sets none, and the chart sets
no OTEL_METRIC_EXPORT_INTERVAL). The window is above two intervals, so a live pod that
loses one export is still read until its next one lands, with 30 seconds for the
export's 10-second timeout and the pipeline's delay. It is below three, the shortest
for: of those rules, so one last sample of a pod that is gone is never an alert by
itself. If you export less often, or scrape these components at a longer interval,
raise the window to two and a half intervals and each for: with it.
What a roll does, run through promtool test rules with an old pod's series ending at
minute 9 and its replacement's starting at minute 10 (the cases are in
agent-pin-alerts_test.yml and registrar-alerts_test.yml, and each "bare selector"
entry is how the same case failed before the window):
| Rule | With a bare selector | With the 150 s window |
|---|---|---|
AetherMeshClusterUnpinned (for: 3m), old pod's last export reported one unpinned cluster, the new pod none |
fired at minutes 12 and 13 | does not fire |
| the same rule, firing on the old pod, the new pod reports none | still firing at minute 13 | ends at minute 12 |
| the same rule, firing, and the new pod reports the cluster too | fires throughout, figure 2 until minute 13 | fires throughout, figure 2 at minutes 10 and 11, then 1 |
AetherMeshClusterPinPending (for: 5m), firing on the old pod |
still firing at minute 13 | ends at minute 12 |
AetherProxyHoldsUnpinnedClusters, AetherProxyPinStateUnknown (for: 15m), firing on the old pod, last sample at minute 25 |
still firing at minute 28 | end at minute 28 |
AetherRegistrarSnapshotDiverged (for: 3m), an old registrar's last export fell between an agent RPC and its sync (a hash that is not its revision's) |
fired at minutes 12 and 13 against the other replicas' hash | does not fire |
| the same rule, the two live replicas diverge from minute 10 and a replaced registrar last reported a write-behind depth of 3 | quiet until minute 13, fires at minute 17 | quiet at minutes 10 and 11, fires at minute 15 |
| the same rule, firing, and the odd replica is replaced by one that agrees | still firing at minute 13 | ends at minute 12 |
Two things the window does not remove, both in the test files as a limit case or in
the row above. A pod whose last two exports reported an unpinned cluster is read for
three and a half minutes, so AetherMeshClusterUnpinned can fire for one evaluation
after the pod is gone. And a replaced registrar's last write-behind depth still delays
a real divergence, by the window.
AetherProxyRejectedListenerUpdate (agent-xds-alerts.yml) has no window: it reports
an event of the last hour from a counter, increase() over a series that has ended
still counts what it counted, and a window would only end a true alert sooner. The
rules in mesh-dns-alerts.yml and agent-cni-alerts.yml read per-node DaemonSets,
whose series carry no pod name and keep their identity across a roll.
Your own rules and panels over the registrar's, the controller's or the edge's series need the same care:
- an aggregation over a gauge (
sum,count,max, a comparison between replicas): wrap the selector inlast_over_time(...[150s]), or it counts replaced pods for five minutes after every roll; - a panel that does not aggregate shows one line per pod, and after a roll the old
pod's line ends and a new one starts. Aggregate away
instance(max without (instance) (...),sum without (instance) (...)) for a line per component; rate()andincrease()are computed per series, which is right for a counter per pod; sum them after, not before.
The node proxy's job label is the same effect, once: see the runbook's "Chart 2.4.26".
The upgrade that crosses chart 2.5.3 is the runbook's "Chart 2.5.3".
Checking a pipeline¶
Each of these should return one result per node. A single result with no node label
means the promotion is missing.
count by (node) (aether_mesh_dns_ready) # one per node, node = the Kubernetes node name
count by (node) (aether_agent_cni_conflist_chained) # the same set of node values
count by (job, node) (aether_agent_snapshot_tls_clusters)
count by (node) (aether_probe_requests_total) # node is a node name, not a prober pod name
The registrar check reads differently. This always returns one result, and its value
has to equal the number of registrar replicas. A value of 1 with more than one replica
running means the replicas write one series.
count(aether_registrar_snapshot_content_hash)
As written it counts a replaced pod for five minutes after a registrar roll. To count the replicas that report now, and to see their names:
count(last_over_time(aether_registrar_snapshot_content_hash[150s]))
count by (instance) (last_over_time(aether_registrar_snapshot_content_hash[150s]))
The second has to return one row per registrar pod, named after it. One row with no
instance means the label is dropped on the way (or the chart is older than 2.5.3). The
same check for the edge control plane, where two replicas may share a node:
count by (node, instance) (last_over_time(aether_agent_snapshot_tls_clusters{job="aether-edge", pin="pinned"}[150s]))
The node proxy and the edge proxy export the same Envoy metric names. This returns one
result per proxy kind: aether-proxy, and aether-edge-proxy when the edge is on. A
result with no job is a node proxy on a chart older than 2.4.26, one whose Envoy has
not yet restarted on the new bootstrap, or, for five minutes after that restart, the
last sample of its old series.
count by (job) (envoy_server_live)
Then prove a rule fires, as README.md says to: break one node and
confirm that the alert names that node.