Metrics Reference
All three components — Controller, Workers, and the ML Service — expose Prometheus-compatible metrics on their HTTP /metrics endpoints (port 8080 for Go components, port 8082 for the ML service). All three route through the org's OTel Metrics API helper library (staffops-otel-libs: otelhelper.MetricsHandler() in Go, otel_helper's Prometheus reader in Python) — no direct client_golang/prometheus_client usage. The cluster (and any other org-specific) label is not emitted by the app; it's added at the scrape layer via the ServiceMonitor's externalLabels (vmagent) or your Prometheus external_labels/relabel config. Queries below assume that label is present from the scrape layer.
Scraping configuration
The chart exposes a single, vendor-neutral scrape mechanism — ServiceMonitor. vm-operator
honors the ServiceMonitor CRD natively on Prometheus clusters, so there is no separate
ServiceMonitor resource to configure.
Controller metrics
The controller exposes metrics about detection cycle orchestration, leader election state, and alert output. These are the primary health indicators for the system.
staffops_ad_controller_cycles_total
| Property |
Value |
| Type |
Counter |
| Labels |
result |
| Description |
Total number of completed detection cycles since startup. The result label is either success or error. A flatlined counter on the leader pod indicates the detection loop has stalled. |
PromQL examples:
# Cycle success rate over the last 5 minutes
rate(staffops_ad_controller_cycles_total{result="success"}[5m])
# Error ratio (alert when above 5%)
rate(staffops_ad_controller_cycles_total{result="error"}[5m])
/
rate(staffops_ad_controller_cycles_total[5m])
> 0.05
staffops_ad_controller_cycle_duration_seconds
| Property |
Value |
| Type |
Histogram |
| Labels |
(none) |
| Buckets |
0.1, 0.5, 1, 2, 5, 10, 30 seconds |
| Description |
End-to-end duration of each detection cycle: from job dispatch to alert firing. High values indicate slow worker responses or a large number of detection rules. |
# p99 cycle duration
histogram_quantile(0.99, rate(staffops_ad_controller_cycle_duration_seconds_bucket[10m]))
# Cycles taking more than 25 seconds (approaching the 30s tick interval)
histogram_quantile(0.95, rate(staffops_ad_controller_cycle_duration_seconds_bucket[10m])) > 25
staffops_ad_controller_is_leader
| Property |
Value |
| Type |
Gauge |
| Labels |
pod |
| Description |
Set to 1 on the pod currently holding the Kubernetes Lease, and 0 on all standbys. Exactly one pod should show 1 at any time. If zero pods show 1, no detection is running. |
# Detect no-leader condition
sum(staffops_ad_controller_is_leader) == 0
# Which pod is the leader?
staffops_ad_controller_is_leader == 1
staffops_ad_controller_anomalies_detected_total
| Property |
Value |
| Type |
Counter |
| Labels |
source, algorithm, severity |
| Description |
Total anomalies detected before deduplication. source is metrics or logs. algorithm is one of static, zscore, ewma, isolation_forest, prophet. Useful for understanding which algorithms contribute the most signals. |
# Anomalies per algorithm over the last hour
sum by (algorithm) (
increase(staffops_ad_controller_anomalies_detected_total[1h])
)
# Ratio of ML-detected anomalies vs rule-based
sum(increase(staffops_ad_controller_anomalies_detected_total{algorithm=~"isolation_forest|prophet"}[1h]))
/
sum(increase(staffops_ad_controller_anomalies_detected_total[1h]))
staffops_ad_controller_alerts_fired_total
| Property |
Value |
| Type |
Counter |
| Labels |
severity, dry_run |
| Description |
Total alerts sent (or would-have-been-sent) to Alertmanager. When controller.dryRun=true, the counter still increments but dry_run="true" in the label — the Alertmanager is not actually called. Use this to validate rule coverage before enabling live alerting. |
# Alert firing rate (last 15 minutes)
rate(staffops_ad_controller_alerts_fired_total{dry_run="false"}[15m])
# Compare dry-run vs live rate after enabling alerting
rate(staffops_ad_controller_alerts_fired_total[15m])
staffops_ad_controller_suppressed_total
| Property |
Value |
| Type |
Counter |
| Labels |
reason |
| Description |
Anomalies that were detected but suppressed before reaching Alertmanager. reason is one of dedup_ttl (same anomaly fired recently), excluded_namespace, or static_excluded_namespace. |
# Suppression breakdown
sum by (reason) (rate(staffops_ad_controller_suppressed_total[10m]))
staffops_ad_detection_anomalies_by_workload_total
| Property |
Value |
| Type |
Counter |
| Labels |
cluster, namespace, workload, severity |
| Description |
Anomalies sliced by cluster/namespace/workload — bounded labels for "top noisy workloads" dashboards, suppression tuning, and drift detection. workload is extracted from pod names (correlation.ExtractWorkload), never a raw pod ID; service-level anomalies use service_name as workload. cluster reflects the monitored workload's own cluster (this deployment queries a federated multi-cluster Prometheus/Loki), not necessarily the cluster the controller itself runs in. Any of the three identity labels default to "unknown" when absent from the source query's by()/group_by. |
# Top 10 noisiest workloads across all clusters, last 24h
topk(10, sum by (cluster, namespace, workload) (
increase(staffops_ad_detection_anomalies_by_workload_total[24h])
))
# Confirm anomalies are attributed to real clusters, not just one
count by (cluster) (staffops_ad_detection_anomalies_by_workload_total)
staffops_ad_detection_fdr_accepted_total / _fdr_rejected_total
| Property |
Value |
| Type |
Counter |
| Labels |
none |
| Description |
Adaptive anomalies accepted / rejected by the per-cycle Benjamini-Hochberg False Discovery Rate filter (controller.fdr_target, default 0.05). Only detector="adaptive" anomalies enter the FDR family; static and pattern anomalies always pass through. |
staffops_ad_detection_fdr_family_size
| Property |
Value |
| Type |
Gauge |
| Labels |
none |
| Description |
The BH family size m used in the last detection cycle — the number of adaptive evaluations performed by workers (past warm-up), fired or not, reported via JobResults.adaptive_series_tested. This is the count FDR corrects over. A value near 0 while anomalies are firing means workers are not reporting tested series and the filter is falling back to the censored (fired-only) family, where BH rejects almost nothing — the exact defect F0 fixed. Expect it to track the number of adaptive series in config.yaml (order of hundreds). |
# FDR rejection ratio — how much multiple-comparison noise is being cut
sum(rate(staffops_ad_detection_fdr_rejected_total[1h]))
/ clamp_min(sum(rate(staffops_ad_detection_fdr_accepted_total[1h]))
+ sum(rate(staffops_ad_detection_fdr_rejected_total[1h])), 1)
# Alert if the family collapses (workers not reporting tested series)
staffops_ad_detection_fdr_family_size < 10
staffops_ad_detection_direction_filtered_total
| Property |
Value |
| Type |
Counter |
| Labels |
none |
| Description |
Adaptive anomalies dropped because they deviated in the harmless direction for their rule (direction-of-badness). A rule declares direction: up_bad \| down_bad \| both_bad (empty = both_bad); the controller drops firings that run the other way (e.g. latency improving) before FDR. A rising counter means the symmetric z-score was catching one-sided-metric noise that this now suppresses. |
staffops_ad_detection_floor_filtered_total
| Property |
Value |
| Type |
Counter |
| Labels |
none |
| Description |
Adaptive anomalies dropped because the current reading did not cross the rule's min_value floor. The z-score is scale-free, so a gauge idling near zero yields a large z for a reading of a few units — statistically anomalous, operationally noise. The floor is applied in the controller before FDR, so floored firings do not consume BH acceptance. Rules without min_value never contribute here. |
# Share of adaptive firings suppressed by the floor (last 1h)
sum(rate(staffops_ad_detection_floor_filtered_total[1h]))
/ clamp_min(sum(rate(staffops_ad_detection_floor_filtered_total[1h]))
+ sum(rate(staffops_ad_detection_fdr_accepted_total[1h])), 1)
A floor that suppresses ~everything means min_value is set too high for that rule — the
detector has gone silent rather than quiet. Compare against staffops_ad_alert_fired_total
before and after a floor change.
Worker metrics
Workers expose metrics about query execution, algorithm performance, and Redis baseline operations. Each worker pod exposes metrics independently; aggregate with sum by (cluster) for cluster-level views.
staffops_ad_worker_jobs_total
| Property |
Value |
| Type |
Counter |
| Labels |
pod, result |
| Description |
Total detection jobs processed by this worker. result is success or error. A high error rate usually indicates connectivity issues with Prometheus, Loki, or Redis. |
# Total job throughput across all workers
sum(rate(staffops_ad_worker_jobs_total{result="success"}[5m]))
# Per-worker error rate
rate(staffops_ad_worker_jobs_total{result="error"}[5m])
staffops_ad_worker_anomalies_suppressed_total
| Property |
Value |
| Type |
Counter |
| Labels |
pod, detector, reason |
| Description |
Anomalies dropped by the suppression filter before reaching the controller. reason is namespace_all (namespace fully excluded), namespace_static (static-only namespace), or adaptive_workload (workload on the adaptive exclude list). Pair with staffops_ad_worker_detections_total (pre-suppression) to see the suppression rate. See Suppression. |
# Suppression rate by reason
sum by (reason) (rate(staffops_ad_worker_anomalies_suppressed_total[10m]))
# Fraction of adaptive detections silenced by the workload exclude list
sum(rate(staffops_ad_worker_anomalies_suppressed_total{reason="adaptive_workload"}[10m]))
/ sum(rate(staffops_ad_worker_detections_total{detector="adaptive"}[10m]))
staffops_ad_worker_query_duration_seconds
| Property |
Value |
| Type |
Histogram |
| Labels |
pod, datasource |
| Buckets |
0.05, 0.1, 0.25, 0.5, 1, 2, 5, 10 seconds |
| Description |
Duration of individual queries to each datasource. datasource is victoriametrics or loki. Slow queries (p99 > 5s) directly lengthen detection cycles and may indicate datasource pressure or overly complex PromQL expressions. |
# p99 query latency per datasource
histogram_quantile(0.99,
sum by (datasource, le) (
rate(staffops_ad_worker_query_duration_seconds_bucket[5m])
)
)
# Slow query alert: p95 Prometheus latency above 3 seconds
histogram_quantile(0.95,
sum by (le) (
rate(staffops_ad_worker_query_duration_seconds_bucket{datasource="victoriametrics"}[5m])
)
) > 3
staffops_ad_worker_redis_cache_hits_total
| Property |
Value |
| Type |
Counter |
| Labels |
pod, operation |
| Description |
Redis cache hits during baseline lookups. operation is baseline_read, seasonal_profile_read, or dedup_check. A hit rate below 80% on baseline_read suggests baselines are expiring too quickly — consider increasing redis.ttl.baseline. |
# Cache hit ratio for baseline reads (last 10 minutes)
sum(rate(staffops_ad_worker_redis_cache_hits_total{operation="baseline_read"}[10m]))
/
sum(rate(staffops_ad_worker_redis_operations_total{operation="baseline_read"}[10m]))
staffops_ad_worker_redis_operations_total
| Property |
Value |
| Type |
Counter |
| Labels |
pod, operation, result |
| Description |
Total Redis operations performed. operation matches the cache hits metric. result is hit, miss, or error. Use alongside redis_cache_hits_total to compute hit ratios. |
# Redis error rate — may indicate connectivity or auth problems
rate(staffops_ad_worker_redis_operations_total{result="error"}[5m])
staffops_ad_worker_baseline_warmup_remaining
| Property |
Value |
| Type |
Gauge |
| Labels |
pod, rule |
| Description |
Number of samples still needed before adaptive detection activates for a given rule. Set to 0 once a rule's baseline has warmed up. Monitoring this gauge after install or after a Redis restart shows warm-up progress. |
# Count of rules still in warm-up across all workers
count(staffops_ad_worker_baseline_warmup_remaining > 0)
# Which specific rules are still warming up?
staffops_ad_worker_baseline_warmup_remaining > 0
staffops_ad_worker_baseline_series_tracked
| Property |
Value |
| Type |
Gauge |
| Description |
Count of distinct baseline series (metric+label combinations) evaluated by this worker process since startup — cardinality watch for the EWMA baseline store. Per-instance; sum() across workers for the cluster-wide total. Resets on pod restart. |
# Total distinct baseline series tracked across all workers
sum(staffops_ad_worker_baseline_series_tracked)
staffops_ad_worker_zscore_current
| Property |
Value |
| Type |
Gauge |
| Labels |
pod, rule, series_hash |
| Description |
Current Z-Score value for each evaluated series. Useful for dashboarding — shows how far each series is from its baseline at the time of last evaluation. Values beyond ±3 indicate anomalous behavior (with default threshold). |
# Top 10 highest Z-Scores right now (potential anomalies)
topk(10, abs(staffops_ad_worker_zscore_current))
ML Service metrics
The ML service exposes metrics about inference requests, model performance, and retraining operations. The ML service metrics endpoint runs on port 8000.
staffops_ad_ml_predictions_total
| Property |
Value |
| Type |
Counter |
| Labels |
model, result |
| Description |
Total inference requests served. model is isolation_forest or prophet. result is anomaly, normal, or error. Track the anomaly rate to understand ML detection volume relative to total predictions. |
# Anomaly detection rate by model
sum by (model) (
rate(staffops_ad_ml_predictions_total{result="anomaly"}[10m])
)
# ML inference error rate
rate(staffops_ad_ml_predictions_total{result="error"}[5m])
staffops_ad_ml_inference_duration_seconds
| Property |
Value |
| Type |
Histogram |
| Labels |
model |
| Buckets |
0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5 seconds |
| Description |
Time spent on each inference call, from request received to response sent, per model. Isolation Forest inference is typically fast (<50ms); Prophet can be slower (100–500ms) depending on forecast horizon and dataset size. |
# p99 inference latency per model
histogram_quantile(0.99,
sum by (model, le) (
rate(staffops_ad_ml_inference_duration_seconds_bucket[5m])
)
)
# Alert if Isolation Forest p95 exceeds 200ms
histogram_quantile(0.95,
sum by (le) (
rate(staffops_ad_ml_inference_duration_seconds_bucket{model="isolation_forest"}[5m])
)
) > 0.2
staffops_ad_ml_retraining_total
| Property |
Value |
| Type |
Counter |
| Labels |
model, result |
| Description |
Total model retraining runs completed. result is success or error. A retraining error means the previous model version remains in use until the next successful retrain. |
# Retraining success rate
rate(staffops_ad_ml_retraining_total{result="success"}[1h])
# Detect consecutive retraining failures (alert if no success in 3× retrain interval)
increase(staffops_ad_ml_retraining_total{result="success"}[3h]) == 0
staffops_ad_ml_retraining_duration_seconds
| Property |
Value |
| Type |
Histogram |
| Labels |
model |
| Buckets |
1, 5, 10, 30, 60, 120, 300 seconds |
| Description |
Time taken to complete a full model retraining cycle. Isolation Forest retraining is typically 5–30 seconds; Prophet can take 30–120 seconds depending on the history depth. If retraining exceeds ml.retrainInterval, consider increasing the interval or reducing the dataset. |
# Latest retraining duration per model
histogram_quantile(0.5,
sum by (model, le) (
rate(staffops_ad_ml_retraining_duration_seconds_bucket[2h])
)
)
staffops_ad_ml_model_training_samples
| Property |
Value |
| Type |
Gauge |
| Labels |
model |
| Description |
Number of training samples used in the most recent retraining run. Prophet requires a minimum of ~336 samples (2 weeks at 30s tick × 5 samples/min aggregated). Low values indicate insufficient historical data. |
# Check if Prophet has enough training data
staffops_ad_ml_model_training_samples{model="prophet"}
# Alert if Prophet training data is below 2-week minimum
staffops_ad_ml_model_training_samples{model="prophet"} < 336
staffops_ad_ml_anomaly_score
| Property |
Value |
| Type |
Gauge |
| Labels |
model, rule |
| Description |
Most recent anomaly score returned by the model for each evaluated rule. For Isolation Forest, lower scores (closer to -1) indicate more anomalous. For Prophet, the score represents the normalized deviation from the forecast confidence interval. |
# Rules with the most anomalous Isolation Forest scores right now
sort_desc(staffops_ad_ml_anomaly_score{model="isolation_forest"})
# Prophet deviations above 2× the uncertainty interval
staffops_ad_ml_anomaly_score{model="prophet"} > 2