Skip to content

Commit 2ce6239

Browse files
committed
Export job state metrics for Prometheus
1 parent 859ff6d commit 2ce6239

4 files changed

Lines changed: 223 additions & 9 deletions

File tree

crates/arroyo-controller/src/lib.rs

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ use arroyo_server_common::wrap_start;
2828
use arroyo_types::{MachineId, PipelineId, WorkerId, from_micros};
2929
use arroyo_worker::job_controller::job_metrics::JobMetrics;
3030
use cornucopia_async::DatabaseSource;
31+
use lazy_static::lazy_static;
32+
use prometheus::{IntGaugeVec, register_int_gauge_vec};
3133
use states::{Created, State, StateMachine};
3234
use std::collections::{HashMap, HashSet};
3335
use std::env;
@@ -51,6 +53,63 @@ pub mod schedulers;
5153
mod states;
5254

5355
const TTL_PIPELINE_CLEANUP_TIME: Duration = Duration::from_secs(60 * 60);
56+
const JOB_STATES: [&str; 6] = [
57+
"running",
58+
"transitioning",
59+
"stopped",
60+
"failed",
61+
"user_failed",
62+
"unknown",
63+
];
64+
65+
lazy_static! {
66+
static ref JOBS_BY_STATE: IntGaugeVec = register_int_gauge_vec!(
67+
"arroyo_controller_jobs",
68+
"Current number of jobs by operational state",
69+
&["state"]
70+
)
71+
.unwrap();
72+
}
73+
74+
fn metric_job_state(
75+
state: Option<&str>,
76+
running_desired: bool,
77+
failure_domain: Option<&str>,
78+
) -> &'static str {
79+
match (state.unwrap_or("Created"), running_desired) {
80+
("Failed", _) if failure_domain == Some("user") => "user_failed",
81+
("Failed", _) => "failed",
82+
("Running", true) => "running",
83+
("Created" | "Stopped" | "Finished", false) => "stopped",
84+
(
85+
"Created" | "Compiling" | "Scheduling" | "Running" | "Rescaling" | "CheckpointStopping"
86+
| "Recovering" | "Restarting" | "Stopping" | "Stopped" | "Finishing" | "Finished"
87+
| "Failing",
88+
_,
89+
) => "transitioning",
90+
_ => "unknown",
91+
}
92+
}
93+
94+
fn job_state_counts<'a>(
95+
jobs: impl Iterator<Item = (Option<&'a str>, bool, Option<&'a str>)>,
96+
) -> HashMap<&'static str, i64> {
97+
let mut counts = HashMap::new();
98+
for (state, running_desired, failure_domain) in jobs {
99+
*counts
100+
.entry(metric_job_state(state, running_desired, failure_domain))
101+
.or_default() += 1;
102+
}
103+
counts
104+
}
105+
106+
fn update_job_state_metrics(counts: &HashMap<&'static str, i64>) {
107+
for state in JOB_STATES {
108+
JOBS_BY_STATE
109+
.with_label_values(&[state])
110+
.set(counts.get(state).copied().unwrap_or_default());
111+
}
112+
}
54113

55114
include!(concat!(env!("OUT_DIR"), "/controller-sql.rs"));
56115

@@ -645,6 +704,15 @@ impl ControllerServer {
645704
while !token.is_cancelled() {
646705
let client = db.client().await?;
647706
let res = queries::controller_queries::fetch_all_jobs(&client).await?;
707+
let state_counts = job_state_counts(res.iter().map(|p| {
708+
(
709+
p.state.as_deref(),
710+
p.stop == StopMode::none,
711+
p.failure_domain.as_deref(),
712+
)
713+
}));
714+
update_job_state_metrics(&state_counts);
715+
648716
for p in res {
649717
let id = Arc::new(p.id);
650718
let config = JobConfig {
@@ -799,3 +867,77 @@ impl ControllerServer {
799867
Ok(local_addr.port())
800868
}
801869
}
870+
871+
#[cfg(test)]
872+
mod tests {
873+
use super::*;
874+
875+
#[test]
876+
fn metric_job_states_use_operational_buckets() {
877+
assert_eq!(metric_job_state(Some("Running"), true, None), "running");
878+
assert_eq!(
879+
metric_job_state(Some("Running"), false, None),
880+
"transitioning"
881+
);
882+
assert_eq!(metric_job_state(Some("Created"), false, None), "stopped");
883+
assert_eq!(
884+
metric_job_state(Some("Created"), true, None),
885+
"transitioning"
886+
);
887+
assert_eq!(metric_job_state(Some("Stopped"), false, None), "stopped");
888+
assert_eq!(
889+
metric_job_state(Some("Stopped"), true, None),
890+
"transitioning"
891+
);
892+
assert_eq!(metric_job_state(Some("Finished"), false, None), "stopped");
893+
assert_eq!(
894+
metric_job_state(Some("Finished"), true, None),
895+
"transitioning"
896+
);
897+
assert_eq!(metric_job_state(None, false, None), "stopped");
898+
899+
for state in [
900+
"Compiling",
901+
"Scheduling",
902+
"Rescaling",
903+
"CheckpointStopping",
904+
"Recovering",
905+
"Restarting",
906+
"Stopping",
907+
"Finishing",
908+
"Failing",
909+
] {
910+
assert_eq!(metric_job_state(Some(state), true, None), "transitioning");
911+
}
912+
913+
assert_eq!(
914+
metric_job_state(Some("Failed"), false, Some("user")),
915+
"user_failed"
916+
);
917+
assert_eq!(
918+
metric_job_state(Some("Failed"), true, Some("internal")),
919+
"failed"
920+
);
921+
assert_eq!(metric_job_state(Some("Unexpected"), true, None), "unknown");
922+
}
923+
924+
#[test]
925+
fn job_state_metrics_clear_absent_states() {
926+
let counts = job_state_counts(
927+
[
928+
(Some("Running"), true, None),
929+
(Some("Running"), true, None),
930+
(Some("Unexpected"), true, None),
931+
]
932+
.into_iter(),
933+
);
934+
update_job_state_metrics(&counts);
935+
assert_eq!(JOBS_BY_STATE.with_label_values(&["running"]).get(), 2);
936+
assert_eq!(JOBS_BY_STATE.with_label_values(&["unknown"]).get(), 1);
937+
938+
update_job_state_metrics(&HashMap::new());
939+
for state in JOB_STATES {
940+
assert_eq!(JOBS_BY_STATE.with_label_values(&[state]).get(), 0);
941+
}
942+
}
943+
}

crates/arroyo-rpc/src/config.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -524,6 +524,9 @@ pub struct AdminConfig {
524524

525525
#[serde(default)]
526526
pub auth_mode: ApiAuthMode,
527+
528+
#[serde(default)]
529+
pub allow_unauthenticated_metrics: bool,
527530
}
528531

529532
#[derive(Debug, Deserialize, Serialize, Clone, Default)]
@@ -1095,6 +1098,11 @@ mod tests {
10951098
jail.set_env("ARROYO__ADMIN__HTTP_PORT", 9111);
10961099
let config: Config = load_config(&[]).extract().unwrap();
10971100
assert_eq!(config.admin.http_port, 9111);
1101+
assert!(!config.admin.allow_unauthenticated_metrics);
1102+
1103+
jail.set_env("ARROYO__ADMIN__ALLOW_UNAUTHENTICATED_METRICS", true);
1104+
let config: Config = load_config(&[]).extract().unwrap();
1105+
assert!(config.admin.allow_unauthenticated_metrics);
10981106

10991107
Ok(())
11001108
});

crates/arroyo-server-common/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,3 +53,5 @@ serde = { workspace = true }
5353
[build-dependencies]
5454
vergen = { version = "8.0.0", features = ["build", "cargo", "git", "gitcl"] }
5555

56+
[dev-dependencies]
57+
tower = { workspace = true, features = ["util"] }

crates/arroyo-server-common/src/lib.rs

Lines changed: 71 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -344,28 +344,48 @@ fn require_profiling_activated(
344344
}
345345
}
346346

347-
pub async fn start_admin_server(service: &str) -> anyhow::Result<()> {
348-
let config = config();
349-
let addr = SocketAddr::new(config.admin.bind_address, config.admin.http_port);
350-
347+
fn admin_router(
348+
service: &str,
349+
auth_mode: &ApiAuthMode,
350+
allow_unauthenticated_metrics: bool,
351+
) -> Router {
351352
let state = Arc::new(AdminState {
352353
name: format!("arroyo-{service}"),
353354
});
354-
let mut app = Router::new()
355-
.route("/status", get(status))
355+
let mut protected = Router::new()
356356
.route("/name", get(root))
357-
.route("/metrics", get(metrics))
358357
.route("/metrics.pb", get(metrics_proto))
359358
.route("/details", get(details))
360359
.route("/config", get(config_route))
361360
.route("/debug/pprof/heap", get(handle_get_heap))
362361
.route("/debug/pprof/profile", get(handle_get_profile))
363362
.with_state(state);
364363

365-
if let ApiAuthMode::StaticApiKey { api_key } = &config.admin.auth_mode {
366-
app = app.layer(ValidateRequestHeaderLayer::bearer(api_key));
364+
if !allow_unauthenticated_metrics {
365+
protected = protected.route("/metrics", get(metrics));
366+
}
367+
368+
if let ApiAuthMode::StaticApiKey { api_key } = auth_mode {
369+
protected = protected.layer(ValidateRequestHeaderLayer::bearer(api_key));
367370
};
368371

372+
// /status is always reachable without auth (e.g. for liveness probes).
373+
let mut public = Router::new().route("/status", get(status));
374+
if allow_unauthenticated_metrics {
375+
public = public.route("/metrics", get(metrics));
376+
}
377+
public.merge(protected)
378+
}
379+
380+
pub async fn start_admin_server(service: &str) -> anyhow::Result<()> {
381+
let config = config();
382+
let addr = SocketAddr::new(config.admin.bind_address, config.admin.http_port);
383+
let app = admin_router(
384+
service,
385+
&config.admin.auth_mode,
386+
config.admin.allow_unauthenticated_metrics,
387+
);
388+
369389
let tls_config =
370390
tls::create_http_tls_config(&config.admin.auth_mode, &config.admin.tls).await?;
371391
if let Some(tls_config) = tls_config {
@@ -535,3 +555,45 @@ pub async fn wrap_start(
535555
)
536556
})
537557
}
558+
559+
#[cfg(test)]
560+
mod tests {
561+
use axum::body::Body;
562+
use axum::http::Request;
563+
use tower::ServiceExt;
564+
565+
use super::*;
566+
567+
fn static_auth() -> ApiAuthMode {
568+
toml::from_str(
569+
r#"
570+
type = "static-api-key"
571+
api-key = "secret"
572+
"#,
573+
)
574+
.unwrap()
575+
}
576+
577+
#[tokio::test]
578+
async fn unauthenticated_metrics_are_configurable() {
579+
let response = admin_router("test", &static_auth(), false)
580+
.oneshot(Request::get("/metrics").body(Body::empty()).unwrap())
581+
.await
582+
.unwrap();
583+
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
584+
585+
let app = admin_router("test", &static_auth(), true);
586+
let response = app
587+
.clone()
588+
.oneshot(Request::get("/metrics").body(Body::empty()).unwrap())
589+
.await
590+
.unwrap();
591+
assert_eq!(response.status(), StatusCode::OK);
592+
593+
let response = app
594+
.oneshot(Request::get("/details").body(Body::empty()).unwrap())
595+
.await
596+
.unwrap();
597+
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
598+
}
599+
}

0 commit comments

Comments
 (0)