Metrics & Alerting¶
The controller exposes Prometheus metrics for monitoring and alerting.
Endpoints¶
| Endpoint | Port | Description |
|---|---|---|
/metrics | 8080 | Prometheus metrics |
/healthz | 8081 | Liveness probe |
/readyz | 8081 | Readiness probe |
Available Metrics¶
Route Synchronization Metrics¶
These metrics track the core synchronization of routes to Cloudflare Tunnel.
| Metric | Type | Labels | Description |
|---|---|---|---|
cftunnel_sync_duration_seconds | Histogram | status | Duration of route sync operations |
cftunnel_synced_routes | Gauge | type | Number of routes synced (http/grpc) |
cftunnel_ingress_rules | Gauge | - | Total ingress rules in tunnel config |
cftunnel_failed_backend_refs | Gauge | type | Failed backend references by route type |
cftunnel_sync_errors_total | Counter | error_type | Sync errors by type |
A sustained run of error_type="proxy_push" errors for one data plane also surfaces on the affected routes as a cf.k8s.lex.la/ProxyConfigPushed=False condition (plus a Warning Event), so a proxy that stops receiving config is visible on route status, not only on this counter. The condition clears on the first successful push.
Cloudflare API Metrics¶
Track Cloudflare API interactions for performance and reliability monitoring.
| Metric | Type | Labels | Description |
|---|---|---|---|
cftunnel_cloudflare_api_duration_seconds | Histogram | method, resource | API call latency |
cftunnel_cloudflare_api_calls_total | Counter | method, resource, status | API calls count |
cftunnel_cloudflare_api_errors_total | Counter | method, error_type | API errors by type |
Label values:
method:get,list,updateresource:tunnel_config,accountstatus:success,errorerror_type:auth,rate_limit,timeout,server_error,network
Ingress Builder Metrics¶
Track the conversion of Gateway API routes to Cloudflare ingress rules.
| Metric | Type | Labels | Description |
|---|---|---|---|
cftunnel_ingress_build_duration_seconds | Histogram | type | Rule building duration |
cftunnel_backend_ref_validation_total | Counter | type, result, reason | Backend ref validation results |
Controller Runtime Metrics¶
Built-in metrics from controller-runtime.
| Metric | Type | Description |
|---|---|---|
controller_runtime_reconcile_total | Counter | Total reconciliations per controller |
controller_runtime_reconcile_errors_total | Counter | Total reconciliation errors |
controller_runtime_reconcile_time_seconds | Histogram | Reconciliation duration |
controller_runtime_max_concurrent_reconciles | Gauge | Max concurrent reconciles |
controller_runtime_active_workers | Gauge | Current active workers |
Workqueue Metrics¶
| Metric | Type | Description |
|---|---|---|
workqueue_adds_total | Counter | Items added to queue |
workqueue_depth | Gauge | Current queue depth |
workqueue_queue_duration_seconds | Histogram | Time in queue |
workqueue_work_duration_seconds | Histogram | Processing time |
workqueue_retries_total | Counter | Item retries |
workqueue_longest_running_processor_seconds | Gauge | Longest running item |
Go Runtime Metrics¶
| Metric | Type | Description |
|---|---|---|
go_goroutines | Gauge | Number of goroutines |
go_gc_duration_seconds | Summary | GC pause duration |
go_memstats_alloc_bytes | Gauge | Allocated memory |
go_memstats_heap_inuse_bytes | Gauge | Heap in use |
Process Metrics¶
| Metric | Type | Description |
|---|---|---|
process_cpu_seconds_total | Counter | CPU time used |
process_resident_memory_bytes | Gauge | Resident memory |
process_open_fds | Gauge | Open file descriptors |
Proxy (Data-Plane) Metrics¶
The in-process L7 proxy serves its own exposition at /metrics on the config API port (8081, no auth — the endpoint carries no secrets; the Bearer token protects config writes). The chart's proxy ServiceMonitor scrapes it when serviceMonitor.enabled is set (and proxy.metrics.enabled is on — the template is gated on both); metrics are on by default and can be disabled with proxy.metrics.enabled: false. The same endpoint also surfaces the embedded cloudflared connector metrics (cloudflared_tunnel_*).
On a multi-tenant shared plane the exposition reveals per-tenant hostname series to anything that can reach the pod, so the chart ships this NetworkPolicy on by default (proxy.networkPolicy.enabled: true) — it admits the config API port (and therefore /metrics) only from the controller's own namespace. To keep Prometheus scraping working, admit your monitoring namespace too: add it to proxy.networkPolicy.ingress.from for the shared proxy, or set proxy.networkPolicy.monitoringNamespaceSelector for the per-Gateway data planes the controller renders. Where the CNI does not enforce NetworkPolicy this is a no-op and scraping is unaffected.
Strict CNIs and kubelet health probes
The proxy's liveness/readiness/startup probes also target the config API port, and they originate from the node's kubelet (host network), not from a pod — so a namespaceSelector ingress rule does not match them. Most CNIs (Calico, Cilium) permit node→pod health-check traffic regardless of NetworkPolicy, so probes work out of the box. A CNI configured to enforce policy on host→pod traffic will block them and CrashLoop the proxy; on such clusters add an ingress rule admitting the kubelet/node source for the config API port, or run with proxy.networkPolicy.enabled: false.
Series count scales with the number of CONFIGURED hostnames — a tenant can legitimately mint series by creating many hostnames under its allowed suffix, so budget Prometheus accordingly on hostname-heavy shared planes.
The hostname label always carries the MATCHED route hostname pattern (exact host, *.suffix wildcard pattern, or empty for default-bucket and unmatched requests) — never the raw client Host — so series cardinality is bounded by the pushed config.
| Metric | Type | Labels | Description |
|---|---|---|---|
cftunnel_proxy_requests_in_flight | Gauge | — | Requests currently being served (excluding hijacked WebSocket sessions). The saturation signal for horizontal scaling. |
cftunnel_proxy_websocket_active_sessions | Gauge | — | Live post-upgrade WebSocket sessions. |
cftunnel_proxy_request_duration_seconds | Histogram | hostname | Wall time from arrival to response completion; WebSocket upgrades observe time-to-upgrade, not session lifetime. |
cftunnel_proxy_requests_total | Counter | hostname, status_class | Completed exchanges by status class: 1xx..5xx, aborted (no response written — e.g. client canceled first), other (handler wrote a status outside 100-599). A WebSocket upgrade counts as 1xx at hijack time. |
cftunnel_proxy_backend_errors_total | Counter | hostname, reason | Backend dial/connect failures (dial, timeout, tls, canceled, ws_dial, ws_handshake, other). |
cftunnel_proxy_response_bytes_total | Counter | hostname | Response body bytes written (post-hijack WebSocket bytes excluded). |
cftunnel_proxy_request_bytes_total | Counter | hostname | Request body bytes read. |
Scaling the proxy on concurrency¶
The proxy is an I/O-bound L7 hop behind an outbound tunnel: CPU is a poor saturation proxy and there is no Service load balancer producing ingress signals. Scale on the in-flight gauge instead. Two ways to feed it to an HPA:
KEDA (Prometheus scaler):
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: proxy-inflight
spec:
scaleTargetRef:
name: <proxy-deployment>
triggers:
- type: prometheus
metadata:
serverAddress: http://prometheus.monitoring:9090
query: sum(cftunnel_proxy_requests_in_flight{job="<release>-proxy"})
threshold: "50"
prometheus-adapter (Pods metric for a native autoscaling/v2 HPA):
# prometheus-adapter values: expose the gauge through the custom-metrics API
rules:
custom:
- seriesQuery: 'cftunnel_proxy_requests_in_flight{namespace!="",pod!=""}'
resources:
overrides:
namespace: {resource: "namespace"}
pod: {resource: "pod"}
metricsQuery: sum(<<.Series>>{<<.LabelMatchers>>}) by (<<.GroupBy>>)
Per-Gateway data planes render this HPA automatically when GatewayConfig.spec.autoscaling is set — see Per-Gateway Isolation.
Prometheus Configuration¶
ServiceMonitor (Prometheus Operator)¶
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: cloudflare-tunnel-gateway-controller
namespace: cloudflare-tunnel-system
labels:
app.kubernetes.io/name: cloudflare-tunnel-gateway-controller
spec:
selector:
matchLabels:
app.kubernetes.io/name: cloudflare-tunnel-gateway-controller
endpoints:
- port: metrics
interval: 30s
path: /metrics
Scrape Config (Prometheus)¶
scrape_configs:
- job_name: cloudflare-tunnel-gateway-controller
kubernetes_sd_configs:
- role: endpoints
namespaces:
names:
- cloudflare-tunnel-system
relabel_configs:
- source_labels: [__meta_kubernetes_service_name]
action: keep
regex: cloudflare-tunnel-gateway-controller
- source_labels: [__meta_kubernetes_endpoint_port_name]
action: keep
regex: metrics
PromQL Queries¶
Route Sync Performance¶
# Sync duration P95
histogram_quantile(0.95,
sum(rate(cftunnel_sync_duration_seconds_bucket[5m])) by (le)
)
# Sync error rate
sum(rate(cftunnel_sync_errors_total[5m])) by (error_type)
# Routes synced by type
sum(cftunnel_synced_routes) by (type)
# Total ingress rules
cftunnel_ingress_rules
# Failed backend references
sum(cftunnel_failed_backend_refs) by (type)
Cloudflare API Health¶
# API success rate
sum(rate(cftunnel_cloudflare_api_calls_total{status="success"}[5m]))
/
sum(rate(cftunnel_cloudflare_api_calls_total[5m]))
# API latency P99
histogram_quantile(0.99,
sum(rate(cftunnel_cloudflare_api_duration_seconds_bucket[5m])) by (le, method)
)
# API errors by type
sum(rate(cftunnel_cloudflare_api_errors_total[5m])) by (method, error_type)
Reconciliation Rate¶
# Reconciliations per second by controller
sum(rate(controller_runtime_reconcile_total[5m])) by (controller)
# Error rate
sum(rate(controller_runtime_reconcile_errors_total[5m])) by (controller)
# Error percentage
sum(rate(controller_runtime_reconcile_errors_total[5m])) by (controller)
/
sum(rate(controller_runtime_reconcile_total[5m])) by (controller)
* 100
Reconciliation Latency¶
# P50 latency
histogram_quantile(0.50,
sum(rate(controller_runtime_reconcile_time_seconds_bucket[5m])) by (le, controller)
)
# P95 latency
histogram_quantile(0.95,
sum(rate(controller_runtime_reconcile_time_seconds_bucket[5m])) by (le, controller)
)
# P99 latency
histogram_quantile(0.99,
sum(rate(controller_runtime_reconcile_time_seconds_bucket[5m])) by (le, controller)
)
Queue Health¶
# Queue depth (should be low)
workqueue_depth{name=~".*gateway.*|.*httproute.*"}
# Average time in queue
sum(workqueue_queue_duration_seconds_sum) by (name)
/
sum(workqueue_queue_duration_seconds_count) by (name)
# Processing time
sum(workqueue_work_duration_seconds_sum) by (name)
/
sum(workqueue_work_duration_seconds_count) by (name)
Resource Usage¶
# Memory usage
process_resident_memory_bytes{job="cloudflare-tunnel-gateway-controller"}
# CPU usage
rate(process_cpu_seconds_total{job="cloudflare-tunnel-gateway-controller"}[5m])
# Goroutines
go_goroutines{job="cloudflare-tunnel-gateway-controller"}
Alerting Rules¶
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: cloudflare-tunnel-gateway-controller
namespace: cloudflare-tunnel-system
spec:
groups:
- name: cloudflare-tunnel-sync
rules:
- alert: CloudflareTunnelSyncErrors
expr: |
sum(rate(cftunnel_sync_errors_total[5m])) > 0.1
for: 5m
labels:
severity: warning
annotations:
summary: "High sync error rate"
description: "Controller experiencing {{ $value | humanize }} sync errors/sec"
- alert: CloudflareTunnelSyncSlow
expr: |
histogram_quantile(0.95,
sum(rate(cftunnel_sync_duration_seconds_bucket[5m])) by (le)
) > 10
for: 10m
labels:
severity: warning
annotations:
summary: "Slow route synchronization"
description: "P95 sync duration is {{ $value | humanizeDuration }}"
- alert: CloudflareTunnelFailedBackendRefs
expr: |
sum(cftunnel_failed_backend_refs) > 0
for: 15m
labels:
severity: warning
annotations:
summary: "Failed backend references"
description: "{{ $value }} backend references are failing validation"
- name: cloudflare-tunnel-api
rules:
- alert: CloudflareTunnelAPIErrors
expr: |
sum(rate(cftunnel_cloudflare_api_errors_total[5m])) by (error_type) > 0
for: 5m
labels:
severity: critical
annotations:
summary: "Cloudflare API errors"
description: "{{ $labels.error_type }} errors: {{ $value | humanize }}/sec"
- alert: CloudflareTunnelAPISlow
expr: |
histogram_quantile(0.99,
sum(rate(cftunnel_cloudflare_api_duration_seconds_bucket[5m])) by (le)
) > 10
for: 10m
labels:
severity: warning
annotations:
summary: "Cloudflare API latency high"
description: "P99 API latency is {{ $value | humanizeDuration }}"
- alert: CloudflareTunnelAPIRateLimited
expr: |
sum(rate(cftunnel_cloudflare_api_errors_total{error_type="rate_limit"}[5m])) > 0
for: 2m
labels:
severity: critical
annotations:
summary: "Cloudflare API rate limited"
description: "Controller is being rate limited by Cloudflare API"
- name: cloudflare-tunnel-controller
rules:
- alert: CloudflareTunnelControllerHighErrorRate
expr: |
sum(rate(controller_runtime_reconcile_errors_total[5m])) by (controller)
/
sum(rate(controller_runtime_reconcile_total[5m])) by (controller)
> 0.1
for: 5m
labels:
severity: warning
annotations:
summary: "High reconciliation error rate"
description: "Controller {{ $labels.controller }} has error rate {{ $value | humanizePercentage }}"
- alert: CloudflareTunnelControllerSlowReconciliation
expr: |
histogram_quantile(0.99,
sum(rate(controller_runtime_reconcile_time_seconds_bucket[5m])) by (le, controller)
) > 30
for: 10m
labels:
severity: warning
annotations:
summary: "Slow reconciliation"
description: "Controller {{ $labels.controller }} P99 latency is {{ $value | humanizeDuration }}"
- alert: CloudflareTunnelControllerQueueBacklog
expr: workqueue_depth{name=~".*gateway.*|.*httproute.*"} > 100
for: 5m
labels:
severity: warning
annotations:
summary: "Workqueue backlog"
description: "Queue {{ $labels.name }} has {{ $value }} items pending"
- alert: CloudflareTunnelControllerDown
expr: up{job="cloudflare-tunnel-gateway-controller"} == 0
for: 2m
labels:
severity: critical
annotations:
summary: "Controller is down"
description: "Cloudflare Tunnel Gateway Controller is not responding"
- alert: CloudflareTunnelControllerHighMemory
expr: |
process_resident_memory_bytes{job="cloudflare-tunnel-gateway-controller"}
> 512 * 1024 * 1024
for: 10m
labels:
severity: warning
annotations:
summary: "High memory usage"
description: "Controller using {{ $value | humanize1024 }}B memory"
Grafana Dashboard¶
Example dashboard panels:
{
"title": "Cloudflare Tunnel Gateway Controller",
"panels": [
{
"title": "Routes Synced",
"type": "stat",
"gridPos": { "x": 0, "y": 0, "w": 6, "h": 4 },
"targets": [
{
"expr": "sum(cftunnel_synced_routes) by (type)",
"legendFormat": "{{ type }}"
}
]
},
{
"title": "Ingress Rules",
"type": "stat",
"gridPos": { "x": 6, "y": 0, "w": 6, "h": 4 },
"targets": [
{
"expr": "cftunnel_ingress_rules"
}
]
},
{
"title": "Failed Backend Refs",
"type": "stat",
"gridPos": { "x": 12, "y": 0, "w": 6, "h": 4 },
"targets": [
{
"expr": "sum(cftunnel_failed_backend_refs)",
"legendFormat": "failed"
}
],
"fieldConfig": {
"defaults": {
"thresholds": {
"steps": [
{ "value": 0, "color": "green" },
{ "value": 1, "color": "red" }
]
}
}
}
},
{
"title": "Sync Duration (P95)",
"type": "timeseries",
"gridPos": { "x": 0, "y": 4, "w": 12, "h": 8 },
"targets": [
{
"expr": "histogram_quantile(0.95, sum(rate(cftunnel_sync_duration_seconds_bucket[5m])) by (le))",
"legendFormat": "p95"
},
{
"expr": "histogram_quantile(0.50, sum(rate(cftunnel_sync_duration_seconds_bucket[5m])) by (le))",
"legendFormat": "p50"
}
],
"fieldConfig": {
"defaults": { "unit": "s" }
}
},
{
"title": "Cloudflare API Latency",
"type": "timeseries",
"gridPos": { "x": 12, "y": 4, "w": 12, "h": 8 },
"targets": [
{
"expr": "histogram_quantile(0.99, sum(rate(cftunnel_cloudflare_api_duration_seconds_bucket[5m])) by (le, method))",
"legendFormat": "p99 {{ method }}"
}
],
"fieldConfig": {
"defaults": { "unit": "s" }
}
},
{
"title": "API Calls/sec",
"type": "timeseries",
"gridPos": { "x": 0, "y": 12, "w": 12, "h": 8 },
"targets": [
{
"expr": "sum(rate(cftunnel_cloudflare_api_calls_total[5m])) by (method, status)",
"legendFormat": "{{ method }} ({{ status }})"
}
]
},
{
"title": "Sync Errors/sec",
"type": "timeseries",
"gridPos": { "x": 12, "y": 12, "w": 12, "h": 8 },
"targets": [
{
"expr": "sum(rate(cftunnel_sync_errors_total[5m])) by (error_type)",
"legendFormat": "{{ error_type }}"
}
]
},
{
"title": "Reconciliations/sec",
"type": "timeseries",
"gridPos": { "x": 0, "y": 20, "w": 12, "h": 8 },
"targets": [
{
"expr": "sum(rate(controller_runtime_reconcile_total[5m])) by (controller)",
"legendFormat": "{{ controller }}"
}
]
},
{
"title": "Reconciliation Latency",
"type": "timeseries",
"gridPos": { "x": 12, "y": 20, "w": 12, "h": 8 },
"targets": [
{
"expr": "histogram_quantile(0.95, sum(rate(controller_runtime_reconcile_time_seconds_bucket[5m])) by (le, controller))",
"legendFormat": "p95 {{ controller }}"
}
],
"fieldConfig": {
"defaults": { "unit": "s" }
}
},
{
"title": "Queue Depth",
"type": "timeseries",
"gridPos": { "x": 0, "y": 28, "w": 12, "h": 8 },
"targets": [
{
"expr": "workqueue_depth",
"legendFormat": "{{ name }}"
}
]
},
{
"title": "Memory Usage",
"type": "stat",
"gridPos": { "x": 12, "y": 28, "w": 6, "h": 4 },
"targets": [
{
"expr": "process_resident_memory_bytes{job=\"cloudflare-tunnel-gateway-controller\"}"
}
],
"fieldConfig": {
"defaults": { "unit": "bytes" }
}
},
{
"title": "Goroutines",
"type": "stat",
"gridPos": { "x": 18, "y": 28, "w": 6, "h": 4 },
"targets": [
{
"expr": "go_goroutines{job=\"cloudflare-tunnel-gateway-controller\"}"
}
]
}
]
}