infra/helm/k8s-monitoring/alerts.md
The monitoring chart sends the signals needed to distinguish a Kubernetes control-endpoint interruption from an etcd stall, a Hetzner load-balancer failure, or a stable outbound-gateway failure.
All queries below are suitable for Grafana-managed alert rules. Use the Grafana Cloud metrics data source and evaluate them every minute.
The metrics cluster label uses tuist-production, tuist-staging,
tuist-canary, and tuist-management. The Cluster API
workload_cluster label uses the Kubernetes Cluster object names tuist,
tuist-staging, and tuist-canary, so production deliberately differs
between these two labels.
min by (cluster, instance) (
min_over_time(up{job="tuist-kube-apiserver"}[2m])
) == 0
Kubernetes control endpoint unavailable on {{ $labels.instance }} ({{ $labels.cluster }})kube_daemonset_status_number_unavailable{
namespace="observability",
daemonset="k8s-monitoring-alloy-control-plane"
} > 0
Control-plane metrics collector unavailable in {{ $labels.cluster }}absent_over_time(up{cluster="tuist-production", job="tuist-kube-apiserver"}[5m])
or
absent_over_time(up{cluster="tuist-production", job="tuist-etcd"}[5m])
or
absent_over_time(up{cluster="tuist-staging", job="tuist-kube-apiserver"}[5m])
or
absent_over_time(up{cluster="tuist-staging", job="tuist-etcd"}[5m])
or
absent_over_time(up{cluster="tuist-canary", job="tuist-kube-apiserver"}[5m])
or
absent_over_time(up{cluster="tuist-canary", job="tuist-etcd"}[5m])
or
absent_over_time(up{cluster="tuist-management", job="tuist-kube-apiserver"}[5m])
or
absent_over_time(up{cluster="tuist-management", job="tuist-etcd"}[5m])
Kubernetes control-plane scrape telemetry is missing for {{ $labels.job }} in {{ $labels.cluster }}sum by (cluster) (
increase(apiserver_request_terminations_total[2m])
) > 0
Kubernetes control endpoint terminated requests in {{ $labels.cluster }}sum by (cluster) (
increase(apiserver_flowcontrol_rejected_requests_total[5m])
) > 0
Kubernetes control endpoint is rejecting requests in {{ $labels.cluster }}min by (cluster, instance) (
etcd_server_has_leader
) == 0
etcd has no leader on {{ $labels.instance }} ({{ $labels.cluster }})min by (cluster, instance) (
min_over_time(up{job="tuist-etcd"}[2m])
) == 0
etcd metrics unavailable on {{ $labels.instance }} ({{ $labels.cluster }})min by (
cluster,
hetzner_load_balancer_name,
hetzner_target_name,
hetzner_target_port
) (
hetzner_load_balancer_service_state{
cluster="tuist-management",
hetzner_load_balancer_name=~"tuist(|-staging|-canary)-.*-kube-apiserver-.*"
}
) == 0
Hetzner load balancer {{ $labels.hetzner_load_balancer_name }} has an unhealthy control-plane target ({{ $labels.cluster }})kube_deployment_status_replicas_available{
cluster="tuist-management",
namespace="org-tuist",
deployment="hcloud-load-balancer-exporter"
}
<
kube_deployment_spec_replicas{
cluster="tuist-management",
namespace="org-tuist",
deployment="hcloud-load-balancer-exporter"
}
Hetzner load-balancer telemetry exporter is unavailableabsent_over_time(
hetzner_load_balancer_service_state{
cluster="tuist-management",
hetzner_load_balancer_name=~"tuist(|-staging|-canary)-.*-kube-apiserver-.*"
}[5m]
)
Hetzner control-plane load-balancer health telemetry is missingkube_customresource_kubeadmcontrolplane_ready_replicas{
cluster="tuist-management",
workload_cluster=~"tuist|tuist-staging|tuist-canary"
}
<
kube_customresource_kubeadmcontrolplane_spec_replicas{
cluster="tuist-management",
workload_cluster=~"tuist|tuist-staging|tuist-canary"
}
Control plane for {{ $labels.workload_cluster }} has fewer ready replicas than desiredabsent_over_time(
kube_customresource_kubeadmcontrolplane_spec_replicas{
cluster="tuist-management"
}[10m]
)
Control-plane desired and ready replica telemetry is missingThe management cluster serves the CAPI/CAPH admission webhooks with a
cert-manager certificate. If it expires — or the controllers keep serving a
stale one after cert-manager renews it, which is what happened on 2026-07-30 —
the API server can no longer call the webhooks, and because they are
failurePolicy: Fail every write to a cluster.x-k8s.io object is rejected
across all workload clusters. Node replacement and autoscaling freeze
fleet-wide (production included; it was spared last time only because nothing
needed replacing). This is the root-cause detector; nothing else here catches
it directly. The rejections surface as calling_webhook_error on the mgmt
API server.
sum by (name) (
rate(
apiserver_admission_webhook_rejection_count{
cluster="tuist-management",
error_type="calling_webhook_error",
name=~".+\.cluster\.x-k8s\.io"
}[5m]
)
) > 0
Cluster API admission webhook {{ $labels.name }} is failing on the management cluster — cluster.x-k8s.io writes are frozen fleet-wideCatches a worker MachineDeployment (the stable-egress gateway pool, or a production processor/kura pool) running with fewer ready nodes than desired — for example when MachineHealthCheck deleted nodes that CAPI then could not recreate. Independent of the stable-egress-gateway signal, so it also covers non-egress pools. Both series are exported by the management cluster's kube-state-metrics CustomResourceState.
kube_customresource_machinedeployment_ready_replicas{
cluster="tuist-management"
}
<
kube_customresource_machinedeployment_spec_replicas{
cluster="tuist-management"
}
Worker pool {{ $labels.machinedeployment }} ({{ $labels.workload_cluster }}) has fewer ready nodes than desiredabsent_over_time(
kube_customresource_machinedeployment_spec_replicas{
cluster="tuist-management"
}[15m]
)
Worker MachineDeployment replica telemetry is missing on the management clustercount by (cluster) (
up{job="integrations/node_exporter"} == 1
)
<
max by (cluster) (
kube_daemonset_status_desired_number_scheduled{
namespace="observability",
daemonset="k8s-monitoring-node-exporter"
}
)
Node-level host metrics are missing for one or more nodes in {{ $labels.cluster }}absent_over_time(
up{cluster="tuist-production", job="integrations/node_exporter"}[10m]
)
or
absent_over_time(
up{cluster="tuist-staging", job="integrations/node_exporter"}[10m]
)
or
absent_over_time(
up{cluster="tuist-canary", job="integrations/node_exporter"}[10m]
)
or
absent_over_time(
up{cluster="tuist-management", job="integrations/node_exporter"}[10m]
)
Node exporter telemetry is missing in {{ $labels.cluster }}max by (cluster) (
tuist_stable_egress_gateway_available
) == 0
No healthy prepared stable outbound gateway in {{ $labels.cluster }}absent_over_time(
tuist_stable_egress_gateway_available{cluster="tuist-production"}[10m]
)
or
absent_over_time(
tuist_stable_egress_gateway_available{cluster="tuist-staging"}[10m]
)
or
absent_over_time(
tuist_stable_egress_gateway_available{cluster="tuist-canary"}[10m]
)
Stable outbound gateway telemetry is missing in {{ $labels.cluster }}sum by (cluster) (
rate(cilium_drop_count_total{
direction="INGRESS",
reason="No Egress IP configured"
}[5m])
) > 0
Cilium is dropping stable outbound traffic in {{ $labels.cluster }}kube_deployment_status_replicas_available{
namespace=~"tuist|tuist-staging|tuist-canary",
deployment="tuist-tuist-server"
}
<
kube_deployment_spec_replicas{
namespace=~"tuist|tuist-staging|tuist-canary",
deployment="tuist-tuist-server"
}
Tuist server has unavailable replicas in {{ $labels.namespace }}min by (cluster, namespace) (
tuist_license_valid
) == 0
or
(
min by (cluster, namespace) (
tuist_license_expiration_timestamp_seconds
)
- time()
) < 604800
Tuist license is invalid or expires within seven days in {{ $labels.cluster }}absent_over_time(
tuist_license_valid{cluster="tuist-production"}[15m]
)
or
absent_over_time(
tuist_license_valid{cluster="tuist-staging"}[15m]
)
or
absent_over_time(
tuist_license_valid{cluster="tuist-canary"}[15m]
)
Tuist license telemetry is missing in {{ $labels.cluster }}Create a Grafana Synthetic Monitoring Hypertext Transfer Protocol check named
tuist-public-readiness for https://tuist.dev, run it every minute from at
least three public probes, and set its Job field to
tuist-public-readiness. Alert when fewer than two probes have succeeded in
the last three minutes:
sum(
max by (probe) (
max_over_time(
probe_success{job="tuist-public-readiness"}[3m]
)
)
) < 2
Tuist is unavailable from multiple external probe locationsabsent_over_time(
probe_success{job="tuist-public-readiness"}[3m]
)
The public endpoint check stopped producing telemetryhistogram_quantile(
0.99,
sum by (cluster, le) (
rate(apiserver_request_duration_seconds_bucket{
verb!~"WATCH|CONNECT"
}[5m])
)
) > 1
Kubernetes request latency above one second in {{ $labels.cluster }}sum by (cluster, instance, priority_level) (
apiserver_flowcontrol_current_executing_seats
)
/
clamp_min(
max by (cluster, instance, priority_level) (
apiserver_flowcontrol_current_limit_seats
),
1
) > 0.8
Kubernetes priority level {{ $labels.priority_level }} uses more than 80% of its request capacity in {{ $labels.cluster }}(
sum by (cluster) (
rate(apiserver_request_total{code=~"5.."}[5m])
)
/
clamp_min(
sum by (cluster) (
rate(apiserver_request_total[5m])
),
1
)
) > 0.01
and
sum by (cluster) (
rate(apiserver_request_total{code=~"5.."}[5m])
) > 0.1
More than 1% of Kubernetes requests are server errors in {{ $labels.cluster }}sum by (cluster) (
rate(apiserver_request_total{code="429"}[5m])
) > 0.1
Kubernetes is rate limiting requests in {{ $labels.cluster }}(
sum by (cluster, namespace, ingress) (
rate(nginx_ingress_controller_requests{status=~"5.."}[5m])
)
/
clamp_min(
sum by (cluster, namespace, ingress) (
rate(nginx_ingress_controller_requests[5m])
),
1
)
) > 0.01
and
sum by (cluster, namespace, ingress) (
rate(nginx_ingress_controller_requests{status=~"5.."}[5m])
) > 0.1
More than 1% of ingress requests are server errors for {{ $labels.ingress }} in {{ $labels.cluster }}sum by (cluster, namespace, method, route) (
increase(tuist_http_request_timeout_count[5m])
) > 5
Bandit reported repeated request read timeouts for {{ $labels.route }} in {{ $labels.cluster }}(
min by (cluster, namespace) (
tuist_license_expiration_timestamp_seconds
)
- time()
) < 2592000
and
min by (cluster, namespace) (
tuist_license_valid
) == 1
Tuist license expires within 30 days in {{ $labels.cluster }}sum by (cluster, namespace, repo, database) (
increase(tuist_repo_pool_checkout_queue_starved_samples_sum[5m])
)
/
clamp_min(
sum by (cluster, namespace, repo, database) (
increase(tuist_repo_pool_checkout_queue_total_samples_sum[5m])
),
1
) > 0.1
More than 10% of database pool samples had queued work and no ready connection for {{ $labels.repo }} in {{ $labels.cluster }}histogram_quantile(
0.99,
sum by (cluster, instance, le) (
rate(etcd_disk_wal_fsync_duration_seconds_bucket[5m])
)
) > 0.5
etcd write-ahead-log synchronization is slow on {{ $labels.instance }}histogram_quantile(
0.99,
sum by (cluster, instance, le) (
rate(etcd_disk_backend_commit_duration_seconds_bucket[5m])
)
) > 0.25
etcd backend commits are slow on {{ $labels.instance }}histogram_quantile(
0.99,
sum by (cluster, instance, le) (
rate(etcd_network_peer_round_trip_time_seconds_bucket[5m])
)
) > 0.1
etcd peer round-trip latency is above 100 milliseconds on {{ $labels.instance }}sum by (cluster) (
increase(etcd_server_leader_changes_seen_total[10m])
) > 0
etcd leadership changed in {{ $labels.cluster }}max by (cluster, instance) (
etcd_server_proposals_pending
) > 100
or
sum by (cluster, instance) (
increase(etcd_server_proposals_failed_total[5m])
) > 0
etcd proposals are stalled or failing on {{ $labels.instance }}sum by (cluster, instance) (
increase(etcd_server_slow_apply_total[5m])
) > 0
etcd reported slow request application on {{ $labels.instance }}max by (cluster, job, instance) (
process_open_fds{
job=~"tuist-kube-apiserver|tuist-etcd"
}
/
clamp_min(
process_max_fds{
job=~"tuist-kube-apiserver|tuist-etcd"
},
1
)
) > 0.8
{{ $labels.job }} uses more than 80% of its file descriptor limit on {{ $labels.instance }}1 - avg by (cluster, instance) (
rate(node_cpu_seconds_total{mode="idle"}[5m])
) > 0.9
Host processor utilization is above 90% on {{ $labels.instance }}avg by (cluster, instance) (
rate(node_cpu_seconds_total{mode="steal"}[5m])
) > 0.1
Virtual machine host contention is stealing processor time from {{ $labels.instance }}max by (cluster, instance) (
rate(node_disk_io_time_seconds_total{
device=~"(sd|vd|xvd)[a-z]+|nvme[0-9]+n[0-9]+"
}[5m])
) > 0.8
Host disk is busy more than 80% of the time on {{ $labels.instance }}max by (cluster, instance) (
(
rate(node_disk_read_time_seconds_total{
device=~"(sd|vd|xvd)[a-z]+|nvme[0-9]+n[0-9]+"
}[5m])
+
rate(node_disk_write_time_seconds_total{
device=~"(sd|vd|xvd)[a-z]+|nvme[0-9]+n[0-9]+"
}[5m])
)
/
clamp_min(
rate(node_disk_reads_completed_total{
device=~"(sd|vd|xvd)[a-z]+|nvme[0-9]+n[0-9]+"
}[5m])
+
rate(node_disk_writes_completed_total{
device=~"(sd|vd|xvd)[a-z]+|nvme[0-9]+n[0-9]+"
}[5m]),
0.001
)
) > 0.05
and
max by (cluster, instance) (
rate(node_disk_reads_completed_total{
device=~"(sd|vd|xvd)[a-z]+|nvme[0-9]+n[0-9]+"
}[5m])
+
rate(node_disk_writes_completed_total{
device=~"(sd|vd|xvd)[a-z]+|nvme[0-9]+n[0-9]+"
}[5m])
) > 1
Host disk operations average more than 50 milliseconds on {{ $labels.instance }}sum by (cluster, instance) (
rate({
__name__=~"node_network_(receive|transmit)_errs_total",
device=~"e(n|th).*"
}[5m])
) > 0
Host network interface reports errors on {{ $labels.instance }}(
sum by (cluster, instance) (
rate({
__name__=~"node_network_(receive|transmit)_drop_total",
device=~"e(n|th).*"
}[5m])
)
/
clamp_min(
sum by (cluster, instance) (
rate({
__name__=~"node_network_(receive|transmit)_packets_total",
device=~"e(n|th).*"
}[5m])
),
1
)
) > 0.001
and
sum by (cluster, instance) (
rate({
__name__=~"node_network_(receive|transmit)_packets_total",
device=~"e(n|th).*"
}[5m])
) > 100
Host network packet drops exceed 0.1% on {{ $labels.instance }}max by (cluster, instance) (
abs(node_timex_offset_seconds)
) > 0.1
Host clock differs from its time source by more than 100 milliseconds on {{ $labels.instance }}sum by (cluster, instance) (
rate(node_netstat_Tcp_RetransSegs[5m])
)
/
clamp_min(
sum by (cluster, instance) (
rate(node_netstat_Tcp_OutSegs[5m])
),
1
) > 0.01
Transmission Control Protocol retransmissions exceed 1% on {{ $labels.instance }}min by (cluster, node) (
tuist_stable_egress_gateway_prepared
) == 0
Stable outbound gateway candidate {{ $labels.node }} is not preparedsum by (cluster) (
max by (cluster, node) (
tuist_stable_egress_gateway_prepared
)
) < 2
Fewer than two stable outbound gateway candidates are prepared in {{ $labels.cluster }}min by (cluster, node) (
tuist_stable_egress_gateway_node_healthy
) == 0
Cilium is not directly reachable on stable outbound gateway {{ $labels.node }}sum by (cluster) (
increase(tuist_stable_egress_failovers_total[10m])
) > 0
The stable outbound address moved to another gateway in {{ $labels.cluster }}sum by (cluster) (
increase(controller_runtime_reconcile_errors_total{
controller="stable-egress-failover"
}[5m])
) > 0
Stable outbound controller reconciliation is failing in {{ $labels.cluster }}Current Kubernetes requests in flight:
sum by (cluster, request_kind) (
apiserver_current_inflight_requests
)
Hetzner load-balancer connections and traffic:
hetzner_load_balancer_open_connections{cluster="tuist-management"}
hetzner_load_balancer_bandwidth_in{cluster="tuist-management"}
hetzner_load_balancer_bandwidth_out{cluster="tuist-management"}
Stable outbound gateway assignments:
tuist_stable_egress_gateway_active
Kubernetes API server and etcd process pressure:
rate(process_cpu_seconds_total{job=~"tuist-kube-apiserver|tuist-etcd"}[5m])
process_resident_memory_bytes{job=~"tuist-kube-apiserver|tuist-etcd"}
process_open_fds{job=~"tuist-kube-apiserver|tuist-etcd"}
/
clamp_min(
process_max_fds{job=~"tuist-kube-apiserver|tuist-etcd"},
1
)
histogram_quantile(
0.99,
sum by (cluster, job, instance, le) (
rate(go_sched_latencies_seconds_bucket{
job="tuist-kube-apiserver"
}[5m])
)
)
Server database pool pressure:
max by (cluster, namespace, repo, database) (
tuist_repo_pool_checkout_queue_length
)
min by (cluster, namespace, repo, database) (
tuist_repo_pool_ready_conn_count
)
absent_over_time and fire even though threshold rules use
No Data: Normal.severity label, and the notification contact
point used by the infrastructure team.The same rules can be created with Grafana Assistant. Give it this prompt:
Create Grafana-managed alert rules from
infra/helm/k8s-monitoring/alerts.md. Use the Grafana Cloud metrics data source,
preserve every query and pending period exactly, put the rules in a folder
named Tuist infrastructure, add the suggested summary as the annotation, and
route critical and warning severities through our existing infrastructure
notification policy. Configure No Data and Error as Alerting for every
explicit telemetry-missing rule. Configure No Data as Normal for every
threshold rule. Configure Error as Alerting for critical availability and
telemetry-missing rules, and Keep Last State for warning rules. Group
notifications by cluster and alert name. Preview each raw metric selector and
final comparison against the last seven days, report any selector with no
matching series, and show me the resulting rules before saving.