Skip to content

Commit e78f7b5

Browse files
authored
Export job state metrics for Prometheus (ArroyoSystems#1114)
1 parent 859ff6d commit e78f7b5

4 files changed

Lines changed: 162 additions & 9 deletions

File tree

crates/arroyo-controller/src/lib.rs

Lines changed: 81 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;
@@ -52,6 +54,43 @@ mod states;
5254

5355
const TTL_PIPELINE_CLEANUP_TIME: Duration = Duration::from_secs(60 * 60);
5456

57+
lazy_static! {
58+
static ref JOBS_BY_STATE: IntGaugeVec = register_int_gauge_vec!(
59+
"arroyo_controller_jobs",
60+
"Current number of jobs by controller state",
61+
&["state"]
62+
)
63+
.unwrap();
64+
}
65+
66+
fn metric_job_state<'a>(state: Option<&'a str>, failure_domain: Option<&str>) -> &'a str {
67+
let state = state.unwrap_or("Created");
68+
if state == "Failed" && failure_domain == Some("user") {
69+
"UserFailed"
70+
} else {
71+
state
72+
}
73+
}
74+
75+
fn job_state_counts<'a>(
76+
jobs: impl Iterator<Item = (Option<&'a str>, Option<&'a str>)>,
77+
) -> HashMap<&'a str, i64> {
78+
let mut counts = HashMap::new();
79+
for (state, failure_domain) in jobs {
80+
*counts
81+
.entry(metric_job_state(state, failure_domain))
82+
.or_default() += 1;
83+
}
84+
counts
85+
}
86+
87+
fn update_job_state_metrics(counts: &HashMap<&str, i64>) {
88+
JOBS_BY_STATE.reset();
89+
for (state, count) in counts {
90+
JOBS_BY_STATE.with_label_values(&[state]).set(*count);
91+
}
92+
}
93+
5594
include!(concat!(env!("OUT_DIR"), "/controller-sql.rs"));
5695

5796
use crate::schedulers::{ManualScheduler, NodeScheduler, ProcessScheduler, Scheduler};
@@ -645,6 +684,12 @@ impl ControllerServer {
645684
while !token.is_cancelled() {
646685
let client = db.client().await?;
647686
let res = queries::controller_queries::fetch_all_jobs(&client).await?;
687+
let state_counts = job_state_counts(
688+
res.iter()
689+
.map(|p| (p.state.as_deref(), p.failure_domain.as_deref())),
690+
);
691+
update_job_state_metrics(&state_counts);
692+
648693
for p in res {
649694
let id = Arc::new(p.id);
650695
let config = JobConfig {
@@ -799,3 +844,39 @@ impl ControllerServer {
799844
Ok(local_addr.port())
800845
}
801846
}
847+
848+
#[cfg(test)]
849+
mod tests {
850+
use prometheus::core::Collector;
851+
852+
use super::*;
853+
854+
#[test]
855+
fn metric_job_states_preserve_raw_states() {
856+
for state in ["Created", "Running", "Finished", "Unexpected"] {
857+
assert_eq!(metric_job_state(Some(state), None), state);
858+
}
859+
assert_eq!(metric_job_state(None, None), "Created");
860+
861+
assert_eq!(metric_job_state(Some("Failed"), Some("user")), "UserFailed");
862+
assert_eq!(metric_job_state(Some("Failed"), Some("internal")), "Failed");
863+
}
864+
865+
#[test]
866+
fn job_state_metrics_clear_absent_states() {
867+
let counts = job_state_counts(
868+
[
869+
(Some("Running"), None),
870+
(Some("Running"), None),
871+
(Some("Failed"), Some("user")),
872+
]
873+
.into_iter(),
874+
);
875+
update_job_state_metrics(&counts);
876+
assert_eq!(JOBS_BY_STATE.with_label_values(&["Running"]).get(), 2);
877+
assert_eq!(JOBS_BY_STATE.with_label_values(&["UserFailed"]).get(), 1);
878+
879+
update_job_state_metrics(&HashMap::new());
880+
assert!(JOBS_BY_STATE.collect()[0].get_metric().is_empty());
881+
}
882+
}

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)