Unreleased documentation. Choose your installed release in the version menu. Features described here may be absent from that release.
Monitoring¶
Use Prometheus to check whether HAPTIC is applying configuration changes and whether HAProxy is serving traffic successfully. Enable the bundled monitors and alerts below, then add the Grafana dashboards.
What to monitor¶
Scrape both the controller and HAProxy pods:
| Source | What it tells you |
|---|---|
Controller (9090) |
Whether configuration validation, deployment, and leader election are working |
HAProxy (8404) |
Traffic volume, connection state, and backend health |
| HAProxy sidecars | Agent health, request metrics, and plugin results; included in the HAProxy PodMonitor |
Enable the bundled monitoring¶
The chart includes controller and HAProxy monitors, alerting rules, and a Grafana
dashboard. If you use Prometheus Operator, enable them together in your Helm
values. This example assumes Prometheus selects resources labeled
release: prometheus and runs in namespace monitoring:
controller:
monitoring:
serviceMonitor:
enabled: true
labels:
release: prometheus
prometheusRule:
enabled: true
labels:
release: prometheus
grafanaDashboard:
enabled: true
networkPolicy:
ingress:
monitoring:
enabled: true
namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: monitoring
podSelector: {}
haproxy:
monitoring:
podMonitor:
enabled: true
labels:
release: prometheus
Apply the values through your Helm deployment.
Your Prometheus installation must select these monitor and rule labels and
watch the HAPTIC namespace. Grafana's dashboard sidecar must also watch that
namespace and the grafana_dashboard: "1" label. See
dashboard setup if you import dashboards manually.
In Prometheus, check that the HAPTIC scrape targets are UP. Open the HAPTIC dashboard in Grafana. Start with fleet convergence, rejected configurations, HAProxy backend health, and request errors; these show whether configuration changes and application traffic are working.
The alert list explains each supplied alert. The metrics reference below supports custom queries and incident investigation. Without Prometheus Operator, use the manual scrape configuration.
Enabling metrics¶
Metrics are enabled by default. The controller serves Prometheus metrics at /metrics on the metrics port (default :9090), which is separate from the debug port. With the default NetworkPolicy, also enable controller monitoring ingress; see Networking.
The chart sets the controller process, container port, Service, and monitors from
one value. To disable the metrics server, set controller.ports.metrics: 0:
controller.ports.metrics=0 can't be combined with an enabled ServiceMonitor,
PodMonitor, or PrometheusRule because those resources would target a listener
that doesn't exist. The chart rejects that combination.
Accessing metrics¶
Prometheus scrape configuration¶
Add a scrape config for the controller:
scrape_configs:
- job_name: 'haptic'
kubernetes_sd_configs:
- role: pod
relabel_configs:
- source_labels: [__meta_kubernetes_namespace]
regex: haptic
action: keep
- source_labels: [__meta_kubernetes_pod_label_app_kubernetes_io_instance]
regex: haptic
action: keep
- source_labels: [__meta_kubernetes_pod_label_app_kubernetes_io_name]
regex: haptic
action: keep
- source_labels: [__meta_kubernetes_pod_label_app_kubernetes_io_component]
regex: controller
action: keep
- source_labels: [__meta_kubernetes_pod_container_port_number]
regex: "9090"
action: keep
ServiceMonitor (Prometheus operator)¶
If using Prometheus Operator, enable the ServiceMonitor in Helm:
# values.yaml
controller:
monitoring:
serviceMonitor:
enabled: true
interval: 30s
labels:
release: prometheus # Match your Prometheus selector
The chart also ships a PodMonitor (controller.monitoring.podMonitor.enabled) for setups that scrape pods directly instead of via the Service — enable whichever your Prometheus setup uses.
Add custom labels, a scrape timeout, relabelings, or metricRelabelings for larger setups:
# values.yaml
controller:
monitoring:
serviceMonitor:
enabled: true
interval: 15s
scrapeTimeout: 10s
labels:
release: prometheus
team: platform
# Stamp a cluster label onto every scraped series
relabelings:
- sourceLabels: [__address__]
targetLabel: cluster
replacement: production
# Drop a metric you don't want to store
metricRelabelings:
- sourceLabels: [__name__]
regex: 'haptic_event_subscribers'
action: drop
If a NetworkPolicy is in effect, also allow Prometheus to reach the metrics port — see Networking.
Manual access¶
In another terminal:
Other scrapers¶
Victoria Metrics accepts the same Prometheus scrape configuration shown above. For Datadog, configure the Datadog Agent to scrape Prometheus metrics:
Metrics reference¶
Find controller and agent metric names, labels, and queries in the metrics reference.
HAProxy data-plane metrics¶
Controller and agent metrics describe configuration delivery. They don't measure application traffic. HAProxy itself exposes a separate Prometheus endpoint carrying the data-plane signals operators usually watch most closely: per-frontend request rates, per-backend response-code breakdowns, and session counts.
The bundled config enables HAProxy's built-in Prometheus exporter on the status frontend (port 8404, path /metrics) by default — it's served from the always-on status-extra-100-prometheus-exporter snippet, so no extra flag is required.
Where to scrape¶
Prometheus scrapes HAProxy's exporter directly on every HAProxy pod, port 8404, path /metrics. The exporter answers on the pod IP whether or not the Vector sidecar is running — the sidecar carries its own series, the request metrics and the SPOA hub's, never HAProxy's.
Turn on the bundled PodMonitor for the HAProxy pod:
It declares one endpoint per metrics port the pod exposes: stats (8404, haproxy_*), agent-metrics (5557, haptic_agent_*), and with the sidecar on vector-metrics (9598, vector_*, spoa_* and the request counter and duration histograms) plus vector-sizes (9599, the byte-size histograms, only while a size family is enabled). With the sidecar off and the SPOA hub on it scrapes the hub's metrics port directly instead. Every endpoint uses the same interval, scrapeTimeout and relabeling settings.
If you prefer a ServiceMonitor to the bundled PodMonitor, this example scrapes HAProxy traffic metrics only. It also requires Prometheus Operator:
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: haproxy
spec:
selector:
matchLabels:
app.kubernetes.io/name: haptic
app.kubernetes.io/component: loadbalancer
endpoints:
- port: stats # 8404
path: /metrics
Without Prometheus Operator, use a plain Prometheus job. This collects HAProxy,
agent, and Vector metrics with the default chart ports from release haptic in
namespace haptic:
scrape_configs:
- job_name: 'haproxy'
kubernetes_sd_configs:
- role: pod
relabel_configs:
- source_labels: [__meta_kubernetes_namespace]
regex: haptic
action: keep
- source_labels: [__meta_kubernetes_pod_label_app_kubernetes_io_instance]
regex: haptic
action: keep
- source_labels: [__meta_kubernetes_pod_label_app_kubernetes_io_component]
regex: loadbalancer
action: keep
- source_labels: [__meta_kubernetes_pod_container_port_number]
regex: "8404|5557|9095|9598|9599"
action: keep
The chart applies the default metric exclusions when a scrape has no explicit query parameters. See the exporter settings to include additional metric families.
Key queries¶
HAProxy labels frontend and backend metrics with proxy (the section name), and server metrics with server:
# Request rate per frontend
sum by (proxy) (rate(haproxy_frontend_http_requests_total[5m]))
# Active sessions per backend
sum by (proxy) (haproxy_backend_current_sessions)
# Backends with no live endpoint
sum by (proxy) (haproxy_backend_active_servers) == 0
The full metric set is HAProxy's own, not HAPTIC's — see the HAProxy Prometheus exporter reference for every exposed series and its labels.
To restore omitted metrics, configure the exporter exclusions. A scraper with its own query parameters overrides those exclusions.
Request metrics¶
These are the rate, errors, and duration signals derived from the access log and dimensioned by route rather than by request URI. They answer questions the haproxy_* families can't: which Ingress is slow, which path returns 502 responses, whether latency is the backend or the network.
Vector enables these metrics by default, under the haptic_ingress_controller_*
prefix. To reuse ingress-nginx dashboards and alerts, check the
metric compatibility notes.
Families¶
| Metric | Type | Measures | Endpoint |
|---|---|---|---|
haptic_ingress_controller_requests |
counter | One per logged request | 9598 |
haptic_ingress_controller_request_duration_seconds |
histogram | Total active time — what the client experienced (%Ta) |
9598 |
haptic_ingress_controller_response_duration_seconds |
histogram | The whole upstream call: connect, headers, and body transfer | 9598 |
haptic_ingress_controller_connect_duration_seconds |
histogram | Establishing the backend connection (%Tc) |
9598 |
haptic_ingress_controller_header_duration_seconds |
histogram | Waiting for the upstream's response headers (%Tr) |
9598 |
haptic_ingress_controller_request_size |
histogram | Request body bytes from the client (%U) |
9599 |
haptic_ingress_controller_response_size |
histogram | Bytes returned to the client (%B) |
9599 |
Use the timers to narrow an investigation: rising connect time points toward backend reachability or connection capacity; rising header time toward the application; and rising total time alone toward request or response transfer. Compare these signals with backend logs before assigning a cause.
The upstream timers are only recorded when the phase happened. A request HAProxy answered itself — a deny, a redirect, a 503 with no live endpoint — increments requests and request_duration_seconds and contributes to neither connect_duration_seconds nor header_duration_seconds. Recording a zero there would report that the backend answered instantly on a request that never reached one. Look at term instead.
Labels¶
Every family carries the same set:
| Label | Value |
|---|---|
status |
HTTP status code, exact |
method |
Request method |
path |
The matched route — the path template you wrote, not the request URI |
namespace, ingress |
The routing resource that owns the route; both empty when HAProxy answered the request itself |
service |
The Kubernetes Service behind the chosen backend |
host |
Request host |
term |
HAProxy's 4-character termination state |
controller_class, controller_namespace, controller_pod |
Which HAPTIC served it |
term is the one label ingress-nginx has no equivalent of, and it's usually the fastest route from "5% of requests are failing" to a cause:
| Value | Meaning |
|---|---|
---- |
Normal completion |
SC-- |
The backend refused or failed the connection |
sH-- |
The backend accepted the connection, then never sent response headers — a server timeout |
sQ-- |
The request timed out waiting in the queue, before any backend was picked |
cD-- |
The client stopped reading mid-transfer |
PR-- |
HAProxy rejected the request itself, before routing |
The full list is in HAProxy's session state at disconnection reference.
# Error rate per Ingress
sum by (namespace, ingress) (rate(haptic_ingress_controller_requests{status=~"5.."}[5m]))
# p99 latency per route
histogram_quantile(0.99, sum by (le, namespace, ingress, path) (
rate(haptic_ingress_controller_request_duration_seconds_bucket[5m])))
# Is it the backend, or the app? Compare connect against header time.
histogram_quantile(0.95, sum by (le) (rate(haptic_ingress_controller_connect_duration_seconds_bucket[5m])))
histogram_quantile(0.95, sum by (le) (rate(haptic_ingress_controller_header_duration_seconds_bucket[5m])))
# Backends timing out or refusing connections
sum by (namespace, ingress, service, term) (
rate(haptic_ingress_controller_requests{term=~"sH..|SC..|sQ.."}[5m])) > 0
# Bandwidth per Ingress
sum by (namespace, ingress) (rate(haptic_ingress_controller_response_size_sum[5m]))
Controlling cardinality¶
Every combination of label values creates a time series. Histograms add a series for each bucket. Reduce storage and processing costs by removing labels or metric families you don't use:
vector:
requestMetrics:
# Each removes a label from ALL families, so the remaining series aggregate
# exactly as they would have without it.
terminationStateLabel: false # `term` — the biggest saving, it multiplies the histograms too
pathLabel: false # also switches off the HAProxy-side route lookup, saving per-request work
hostLabel: false # the equivalent of ingress-nginx's --metrics-per-host
# Or drop whole families. The four durations are independent of each other.
metrics:
connect_duration_seconds: false
header_duration_seconds: false
request_size: false
response_size: false
Bucket boundaries are the other multiplier — durationBuckets and sizeBuckets in Chart values reference.
requestMetrics.cardinalityLimit limits each label to 500 distinct values per
metric by default. Beyond that, new series omit the label and aggregate together.
Request totals remain, but that label's detail is lost. The limit resets when
Vector restarts; remove unbounded labels if it repeatedly triggers.
The access log is lossy under back-pressure
These metrics are counted from access-log records, not in the data path, so they report fewer requests than were served whenever HAProxy drops records — see The access log is lossy under back-pressure. Keep the HAProxyAccessLogRecordsDropped alert on. If you need a request count that stays exact through a drop, set extraContext.prometheusExporter.excludeMetrics.httpRequestCounters.enabled: false to keep HAProxy's own counters alongside these.
A route that receives no requests for over a minute drops out of the exposition and its counter restarts from zero when traffic returns. rate() and increase() handle the reset, and it keeps idle routes from accumulating series.
Alerting rules¶
Start with the bundled rules, then add latency and error-rate alerts using your application's targets.
Shipped alerts¶
The chart's PrometheusRule deploys these fifteen alerts when controller.monitoring.prometheusRule.enabled: true. Each is toggled by its own controller.monitoring.prometheusRule.defaultRules.<key> flag (all default to true):
| Alert | Toggle key (defaultRules.<key>) |
Fires when |
|---|---|---|
HAProxyControllerReconciliationErrors |
reconciliationErrors |
rate(haptic_reconciliation_errors_total[5m]) > 0 for 5m |
HAProxyControllerDeploymentFailures |
deploymentFailures |
rate(haptic_deployment_errors_total[5m]) > 0 for 2m |
HAProxyFleetDiverged |
fleetDiverged |
haptic_haproxy_fleet_converged < haptic_haproxy_fleet_size for 5m |
HAProxyControllerHighQueueDepth |
highQueueDepth |
p95 haptic_reconciliation_queue_wait_seconds over 5s for 5m |
HAProxyControllerNoLeader |
leaderElectionLost |
sum by (namespace, job) (haptic_leader_election_is_leader) == 0 for 1m |
HAProxyControllerConfigRejected |
configRejected |
increase(haptic_config_rejected_total[5m]) > 0 for 1m |
HAProxyControllerConfigPinned |
configPinned |
haptic_config_pinned > 0 for 5m |
HAProxyControllerHAProxyPodsRejected |
haproxyPodsRejected |
increase(haptic_haproxy_pods_rejected_total[5m]) > 0 for 5m |
HAProxyControllerNoHAProxyPods |
noHAProxyPods |
haptic_resource_count{type="haproxy-pods"} < 1 for 5m |
HAProxyControllerCriticalEventsDropped |
criticalEventsDropped |
increase(haptic_events_dropped_critical_total[5m]) > 0 |
HAProxyAgentApplyRejected |
applyRejected |
increase(haptic_apply_rejected_total[5m]) > 0 for 1m |
HAProxyAgentInvariantViolated |
agentInvariantViolated |
increase(haptic_agent_invariant_violations_total{name!="recovery_reload"}[5m]) > 0 |
HAProxyAgentRecoveryReloadFailed |
recoveryReloadFailed |
increase(haptic_agent_invariant_violations_total{name="recovery_reload"}[5m]) > 0 |
HAProxyAgentVersionSkew |
agentVersionSkew |
increase(haptic_agent_version_skew_total[15m]) > 0 for 30m |
HAProxyAccessLogRecordsDropped |
accessLogDropped |
increase(haproxy_process_dropped_logs_total[5m]) > 0 |
Turn one rule off, or replace the whole set with your own:
# values.yaml
controller:
monitoring:
prometheusRule:
enabled: true
defaultRules:
highQueueDepth: false # drop a single shipped rule; the other fourteen stay
# Or set `rules:` to a non-empty list to replace ALL default rules with your own:
# rules:
# - alert: MyCustomAlert
# expr: ...
The full names, toggle keys, and default thresholds also appear on the Chart Values Reference.
Dashboard examples¶
Enable controller.monitoring.grafanaDashboard.enabled as shown above to install
the supplied dashboard. If Grafana doesn't use a dashboard-discovery sidecar,
export the same JSON from the default release and import it in Grafana:
kubectl get configmap haptic-grafana-dashboard -n haptic \
-o jsonpath='{.data.haptic\.json}' > haptic-dashboard.json
Choose Dashboards → New → Import and upload haptic-dashboard.json.
Select the Prometheus data source that scrapes HAPTIC. You don't need to build a
dashboard from the metric catalogue.
Troubleshoot monitoring¶
| Symptom | Next check |
|---|---|
| Controller target is missing | Confirm the ServiceMonitor labels and namespace match your Prometheus selectors. |
| Scrape target is down | Check the metrics port and monitoring ingress policy. |
| Controller metrics appear but traffic metrics don't | Enable the HAProxy PodMonitor; the controller monitor doesn't scrape HAProxy. |
| Fleet remains behind the desired configuration | Read fleet diagnostics and the rejected configuration or apply message. |
| No leader or frequent leadership changes | Follow high-availability troubleshooting. |
| Resource use keeps growing | Compare traffic, resource counts, and the sizing guide. |
See also¶
- Debugging Guide - Runtime introspection and troubleshooting
- High Availability - Leader election configuration
- Troubleshooting Guide - General troubleshooting