0040. The UI backend gets one metric, the controller gets zero, and monitoring stays optional in the chart
Status: Accepted · Date: 2026-10-26 · Design section: Monitoring and registry mirrors
Decided: added exactly one custom metric (ichor_uiapi_rpc_duration_seconds, via a single connectrpc.com/connect interceptor) because the UI backend exports nothing on its own; added zero custom metrics to the controller because controller-runtime already exports reconcile/workqueue health and kube-state-metrics already covers cluster-level state (pending approvals, the cluster lock, TalosOperation progress) from fields already in CR spec/status; shipped a ServiceMonitor for ichor-ui, one PrometheusRule with two alerts, and one Grafana dashboard, all as chart options defaulting off, the same opt-in shape metricsCert.enabled already established (ADR 0031).
Context
architecture.md's "Monitoring and registry mirrors" section requires: "The controller
and UI backend export Prometheus metrics, and ServiceMonitors, alert rules
and Grafana dashboards ship with the chart as an option." It also requires
Ichor to "expose the control-plane metrics that Talos binds locally by
default (controller-manager, scheduler, etcd) and restrict scraping to a
CIDR with Talos's ingress firewall" -- that second sentence is Talos machine
config, owned by a different unit working in pkg/render's system layer,
and is out of scope here; see "Left to another unit" below for what it
still needs.
ADR 0031 already settled the controller's metrics endpoint and its
optional certificate (metricsCert.enabled). What was still open: which
metrics actually need writing, whether the UI backend needs an endpoint at
all, and how many dashboards/alerts are enough.
Decided: zero new controller metrics
controller-runtime v0.24.1 registers eight metrics unconditionally, with
no code required on Ichor's part (sigs.k8s.io/controller-runtime/pkg/ internal/controller/metrics, confirmed by reading the vendored source, not
assumed): controller_runtime_reconcile_total (by controller, result),
controller_runtime_reconcile_errors_total, controller_runtime_terminal_ reconcile_errors_total, controller_runtime_reconcile_panics_total,
controller_runtime_reconcile_time_seconds, controller_runtime_max_ concurrent_reconciles, controller_runtime_active_workers, and
controller_runtime_reconcile_timeouts_total. "The controller exports
Prometheus metrics" is true before this unit writes a single line.
The real question was which domain-specific facts those don't cover. Four candidates were weighed, and all four turned out to already be answerable without adding anything to the controller:
- Reconcile count/latency/error rate per controller. Exactly
controller_runtime_reconcile_total,_time_seconds,_errors_total. Adding an Ichor-side counter here would just be a second copy of a metric that already exists. - Workqueue depth / stuck reconciles.
controller_runtime_active_ workersand_max_concurrent_reconcilesalready cover "is this controller keeping up." Nothing to add. - How many machines are pending approval, right now (a gauge, not a
counter).
TalosMachine.spec.approvalState(api/v1alpha1/ talosmachine_types.go) is a plain enum field already on the object.kube-state-metrics's Custom Resource State (CRS) feature reads arbitrary JSONPaths -- spec or status -- from any CRD and turns them into gauges with no code, only a YAML config the cluster operator supplies to their ownkube-state-metricsdeployment. CountingapprovalState: pendingis exactly that kind of mapping. Ichor adding its own gauge here would duplicate a fact already sitting in the CR, read by a tool this project doesn't install (architecture.md: Ichor installs no monitoring stack) but that most clusters already run for HPA and other autoscaling. - Whether the cluster lock is held, and by whom.
pkg/clusterlock(pkg/clusterlock/clusterlock.go) is, by its own package doc, "a singlecoordination.k8s.io/v1Lease per cluster."kube-state-metricsshipskube_lease_ownerandkube_lease_renew_timefor everyLeaseobject in the cluster by default -- no CRS config, no code, nothing Ichor-side at all. "Is the lock stale" istime() - kube_lease_renew_time > leaseDurationSeconds; "who holds it" iskube_lease_owner. Already fully answered. TalosOperationphase and per-machine progress.status.phaseandstatus.machines[].phase(api/v1alpha1/talosoperation_types.go) are exactly the shape CRS'sstateSetand list-valued metric types are built for. Same reasoning as the approval-state case: a documentedkube-state-metricsCRS config, not an Ichor-side gauge.
None of this ships from Ichor: architecture.md is explicit that the
monitoring stack itself -- including whatever reads these CR fields -- is
"a documented set of Helm values, or nothing," never something Ichor
installs. A kube-state-metrics CRS snippet covering approvalState and
TalosOperation.status is worth documenting for an operator who already
runs kube-state-metrics, but writing and shipping that config is exactly
the kind of optional-component choice architecture.md reserves for the
user, not this unit. It is not written here, to avoid the same silent-
failure shape ADR 0031 rejected for its bootstrap certificate: a config
file committed into this repo, decoupled from whatever CRS version and
schema the user's actual kube-state-metrics deployment runs, would look
authoritative while quietly drifting out of sync with nothing to catch it.
This also settles why no "build info" gauge (version/commit labels, a
common pattern elsewhere) was added: nothing in this unit's scope
(cmd/, dist/chart/, config/, internal/uiapi/) currently threads a
version string through ldflags, and wiring that up is a build-tooling
change with its own blast radius, not a one-line consequence of a
monitoring-options unit.
Decided: one metric for the UI backend, via one interceptor
Unlike the controller, ichor-ui (cmd/ichor-ui/main.go) had no metrics
endpoint before this unit -- it is a plain net/http server with no
controller-runtime manager underneath it, so there was nothing free here.
architecture.md requires it to export something, and the one domain fact
worth having is Connect RPC latency and outcome, per procedure: is
ApproveMachine slow, is StreamNodeLogs erroring.
Checked before writing any per-handler instrumentation: connectrpc.com/ connect v1.21.0 ships connect.Interceptor (WrapUnary,
WrapStreamingClient, WrapStreamingHandler), read directly from the
vendored source (connectrpc.com/connect@v1.21.0/interceptor.go) rather
than from memory. That is the mechanism to use instead of timing code
scattered across ClusterService/MachineService/NodeService/
PatchService's handlers. internal/uiapi/metrics.go adds one
connect.Interceptor (MetricsInterceptor) observing one histogram,
ichor_uiapi_rpc_duration_seconds{procedure, code}, registered once per
*ServiceHandler constructor via connect.WithInterceptors in cmd/ ichor-ui/main.go. No separate error-rate counter: the histogram's code
label already carries that signal (code != "ok"), and a second counter
duplicating part of what _count already gives would be exactly the kind
of metric rejected above for the controller.
/metrics is served from the same http.ServeMux, the same port, and
plain HTTP -- no second listener, no TLS, no metricsCert-style opt-in
certificate. ichor-ui is reached only in-cluster today (architecture.md
"Trust": a port-forward, no exposed Service), the same trust boundary the
Connect API itself already sits behind, so a second port with its own
TLS/auth story would protect nothing a scrape of the first port doesn't
already cross. github.com/prometheus/client_golang was already an
indirect dependency (pulled in by controller-runtime); using it directly
for promhttp.Handler() and promauto added zero new dependencies
(go.mod's diff is a one-line promotion from indirect to direct).
Decided: one Grafana dashboard, two alerts
Dashboard: one, not several. "Ichor overview" (dist/chart/templates/ grafana/dashboard-configmap.yaml) carries five panels: reconcile rate and
error rate by controller, reconcile duration p99, and ichor-ui's RPC
latency p99 and error rate by procedure -- everything this unit's own
metrics cover, on one screen. A dashboard is a JSON blob that rots the
moment a panel's metric name or label changes and nothing re-checks it;
splitting one screen's worth of panels into multiple dashboards would only
multiply that liability for no reader benefit. Shipped as a ConfigMap
labeled grafana_dashboard: "1", the sidecar-discovery convention both
kube-prometheus-stack's and Bitnami's Grafana charts support -- inert
unless the cluster's own Grafana runs that sidecar, consistent with Ichor
installing no Grafana itself.
Alerts: two. Both pass the "would this wake someone up" bar the task set, and nothing else proposed did:
IchorControllerDown(up{service="ichor-controller-manager-metrics- service"} == 0for 5m,severity: critical): the controller is the only writer of Talos nodes and the only enforcer of deletion protection (architecture.md "Trust"); down means neither is happening.IchorReconcileErrorsPersisting(sum by (controller) (rate(controller_runtime_reconcile_errors_total[15m])) > 0for 15m,severity: warning): a single failed reconcile is retried and is noise; a sustained nonzero rate over 15 minutes means something the controller depends on is persistently broken, not transiently slow.
Both rules are deliberately built only on metrics Ichor itself guarantees
are present (up from the controller's own ServiceMonitor,
controller_runtime_reconcile_errors_total from controller-runtime
itself) -- not on kube_lease_* or any CRS-derived series from the
previous section's reasoning, since those depend on a kube-state-metrics
deployment and config Ichor does not install or guarantee exists. An alert
rule that silently never fires because its metric was never scraped is the
same failure shape ADR 0031 already rejected for a different reason
(the bootstrap-certificate amendment): a mechanism that looks like safety
and is actually silent absence.
Chart options, all off by default
prometheus.enabled(pre-existing, ADR 0031) now also renders ichor-ui's ownServiceMonitor(templates/prometheus/ichor-ui-metrics-monitor. yaml, fixed nameichor-ui-metrics-monitor, matchingichor-ui-service's own fixed-name reasoning) alongside the controller's.prometheus.rules.enabled(new): thePrometheusRule(templates/prometheus/alert-rules.yaml), independent ofprometheus.enabled-- a user might want theServiceMonitorwithout opting into alerting, or vice versa onceprometheus-operator'sPrometheusRuleCRD exists.grafana.dashboards.enabled(new): the dashboard ConfigMap (templates/grafana/dashboard-configmap.yaml).
All three default false, the same shape metricsCert.enabled already
uses (ADR 0031): rendering nothing is the default, each flag renders
exactly its own resource, verified by helm template both ways
(dist/chart/chart_test.go's checkMonitoringOptionsOffByDefault, run as
part of TestChartInvariants's existing default-values render, and the
new TestMonitoringOptionsEnabled, which turns all three on and checks the
UI ServiceMonitor exists, both named alerts are present in the rendered
PrometheusRule, and the dashboard ConfigMap's ichor-overview.json value
is valid JSON with at least one panel -- not just that a ConfigMap
exists, the same "actually render it, don't just read the template"
standard ADR 0031's own TestMetricsCertEnabled sets).
config/prometheus/ (the kustomize mirror, CONTRIBUTING.md's "mirroring
the matching config/ base when one exists") gained ui-monitor.yaml and
alert-rules.yaml, bundled behind the existing single [PROMETHEUS]
toggle in config/default/kustomization.yaml -- kustomize's coarser
one-switch convention doesn't need the chart's finer
prometheus.enabled/prometheus.rules.enabled split to stay useful for
local dev. The Grafana dashboard has no config/ mirror: no config/ grafana/ directory existed before this unit, nothing else in config/
wires up Grafana's sidecar-discovery convention, and kustomize's audience
(contributors rendering config/default for local iteration) has no
Grafana to discover it. Verified empirically, not just by reading the
templates: temporarily uncommented ../prometheus in config/default/ kustomization.yaml, ran make build-installer, confirmed
ichor-alert-rules and ichor-ichor-ui-metrics-monitor both render with
the names this ADR states (the double ichor-ichor- prefix on the UI one
matches the pre-existing convention every other config/manager-ui
resource already has, not a new defect), then reverted.
Left to another unit: Talos control-plane metrics
architecture.md's "expose the control-plane metrics that Talos binds
locally by default (controller-manager, scheduler, etcd) and restrict
scraping to a CIDR with Talos's ingress firewall" is Talos machine
configuration -- a machine.controlPlane-level binding (or equivalent) plus
machine.network.kubespan/ingress-firewall-style CIDR rules -- that belongs
in pkg/render's system layer, not in the chart or internal/uiapi. This
unit's own scope (dist/chart/, config/, internal/uiapi/, cmd/,
docs/) does not include pkg/render, and another agent is actively
working there, so nothing was implemented. What that unit still needs to
decide and build:
- Which of
controller-manager/scheduler/etcd's locally-bound metrics ports get exposed, and on which typedTalosConfigPatchfield(s) (ADR 0003's typed-mirror types, not raw YAML -- CLAUDE.md's "Machine config is typed only"). - The ingress-firewall CIDR restriction that scopes who can reach those ports once exposed -- without it, "expose the metrics" and "open the control plane to the whole pod network" are the same change.
- Whether exposing these needs its own chart/config option (this unit's
prometheus.enabledonly covers the controller and ichor-ui's own/metrics, served from this Kubernetes cluster, not a Talos node's).
Consequences
internal/uiapi/metrics.goadds one histogram (ichor_uiapi_rpc_ duration_seconds) and oneconnect.Interceptor(MetricsInterceptor), wired into every*ServiceHandlerconstructor incmd/ichor-ui/main.goviaconnect.WithInterceptors.cmd/ichor-ui/main.goserves/metrics(promhttp.Handler()) on its existing mux and port -- no new listener, no new chart Service port.- The controller (
internal/controller/,cmd/main.go's manager wiring) is untouched: zero new metrics, per the reasoning above. Anyone wanting cluster-state gauges (pending approvals, lock status,TalosOperationprogress) pointskube-state-metrics's CRS config at the CR fields andLeaseobject that already carry them. dist/chart/templates/prometheus/ichor-ui-metrics-monitor.yaml,alert-rules.yaml, anddist/chart/templates/grafana/ dashboard-configmap.yamlare new, each gated by its ownvalues.yamlflag (prometheus.enabled,prometheus.rules.enabled,grafana.dashboards.enabled), all defaultingfalse.dist/chart/templates/ui/ui-service.yamlandconfig/manager-ui/ service.yamlboth name their porthttpso the newServiceMonitors can target it by name, mirroringmetrics/controller-manager-metrics- service.yaml's own named port.config/prometheus/gainedui-monitor.yamlandalert-rules.yaml, wired into the existing[PROMETHEUS]toggle; noconfig/grafana/directory was created (reasoning above).dist/chart/chart_test.gogainedcheckMonitoringOptionsOffByDefault(wired intoTestChartInvariants) andTestMonitoringOptionsEnabled.go.mod:github.com/prometheus/client_golangmoved from an indirect to a direct dependency; no new dependency was added.- Talos control-plane metrics binding and ingress-firewall CIDR scoping
remain unimplemented, left to the unit already working in
pkg/render's system layer (see above). - Update: the Talos control-plane metrics binding and the ingress-firewall CIDR restriction were built in ADR 0069 (
spec.controlPlaneMetrics).