# 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_
  workers` and `_max_concurrent_reconciles` already 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 own `kube-state-metrics` deployment. Counting `approvalState:
  pending` is 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 single
  `coordination.k8s.io/v1` Lease per cluster." `kube-state-metrics` ships
  `kube_lease_owner` and `kube_lease_renew_time` for every `Lease` object in
  the cluster *by default* -- no CRS config, no code, nothing Ichor-side at
  all. "Is the lock stale" is `time() - kube_lease_renew_time >
  leaseDurationSeconds`; "who holds it" is `kube_lease_owner`. Already
  fully answered.
- **`TalosOperation` phase and per-machine progress.** `status.phase` and
  `status.machines[].phase` (`api/v1alpha1/talosoperation_types.go`) are
  exactly the shape CRS's `stateSet` and list-valued metric types are built
  for. Same reasoning as the approval-state case: a documented
  `kube-state-metrics` CRS 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"} == 0` for 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])) > 0` for 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
  own `ServiceMonitor` (`templates/prometheus/ichor-ui-metrics-monitor.
  yaml`, fixed name `ichor-ui-metrics-monitor`, matching `ichor-ui-service`'s
  own fixed-name reasoning) alongside the controller's.
- `prometheus.rules.enabled` (new): the `PrometheusRule`
  (`templates/prometheus/alert-rules.yaml`), independent of
  `prometheus.enabled` -- a user might want the `ServiceMonitor` without
  opting into alerting, or vice versa once `prometheus-operator`'s
  `PrometheusRule` CRD 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 typed `TalosConfigPatch` field(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.enabled` only covers the controller and ichor-ui's own
  `/metrics`, served from this Kubernetes cluster, not a Talos node's).

## Consequences

- `internal/uiapi/metrics.go` adds one histogram (`ichor_uiapi_rpc_
  duration_seconds`) and one `connect.Interceptor`
  (`MetricsInterceptor`), wired into every `*ServiceHandler` constructor in
  `cmd/ichor-ui/main.go` via `connect.WithInterceptors`.
- `cmd/ichor-ui/main.go` serves `/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, `TalosOperation`
  progress) points `kube-state-metrics`'s CRS config at the CR fields and
  `Lease` object that already carry them.
- `dist/chart/templates/prometheus/ichor-ui-metrics-monitor.yaml`,
  `alert-rules.yaml`, and `dist/chart/templates/grafana/
  dashboard-configmap.yaml` are new, each gated by its own `values.yaml`
  flag (`prometheus.enabled`, `prometheus.rules.enabled`,
  `grafana.dashboards.enabled`), all defaulting `false`.
- `dist/chart/templates/ui/ui-service.yaml` and `config/manager-ui/
  service.yaml` both name their port `http` so the new `ServiceMonitor`s
  can target it by name, mirroring `metrics/controller-manager-metrics-
  service.yaml`'s own named port.
- `config/prometheus/` gained `ui-monitor.yaml` and `alert-rules.yaml`,
  wired into the existing `[PROMETHEUS]` toggle; no `config/grafana/`
  directory was created (reasoning above).
- `dist/chart/chart_test.go` gained `checkMonitoringOptionsOffByDefault`
  (wired into `TestChartInvariants`) and `TestMonitoringOptionsEnabled`.
- `go.mod`: `github.com/prometheus/client_golang` moved 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`).
