summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJesper Jensen <jesper@jnsn.dev>2026-02-06 19:34:39 +0100
committerJesper Jensen <jesper@jnsn.dev>2026-02-06 19:34:39 +0100
commitc2687b44edb4da3b258f4e28c6add8713c50bcec (patch)
tree8447d8408cf8192ad7f99d64db4b3243dfc4f459
parentdfd1533b4a7953ab41e921a4cb563ff6bf120992 (diff)
Add metrics
-rw-r--r--Cargo.lock31
-rw-r--r--Cargo.toml1
-rw-r--r--src/main.rs69
-rw-r--r--src/metrics.rs147
4 files changed, 244 insertions, 4 deletions
diff --git a/Cargo.lock b/Cargo.lock
index 43ab354..090216f 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -33,6 +33,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236"
[[package]]
+name = "ascii"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16"
+
+[[package]]
name = "autocfg"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -92,6 +98,12 @@ dependencies = [
]
[[package]]
+name = "chunked_transfer"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901"
+
+[[package]]
name = "core-foundation-sys"
version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -237,6 +249,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
+name = "httpdate"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
+
+[[package]]
name = "iana-time-zone"
version = "0.1.64"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -695,6 +713,18 @@ dependencies = [
]
[[package]]
+name = "tiny_http"
+version = "0.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82"
+dependencies = [
+ "ascii",
+ "chunked_transfer",
+ "httpdate",
+ "log",
+]
+
+[[package]]
name = "tinyjson"
version = "2.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -764,6 +794,7 @@ dependencies = [
"rand",
"regex",
"rusqlite",
+ "tiny_http",
"tinyjson",
"ureq",
"yaml-rust2",
diff --git a/Cargo.toml b/Cargo.toml
index ab19366..6e14f50 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -18,5 +18,6 @@ rand = "0.9.2"
regex = "1.12.2"
rusqlite = { version = "0.38.0", features = ["chrono"] }
tinyjson = "2.5.1"
+tiny_http = "0.12"
ureq = "3.1.4"
yaml-rust2 = "0.10.4"
diff --git a/src/main.rs b/src/main.rs
index a4a731a..ff3e596 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -3,6 +3,7 @@ mod version;
mod docker;
mod manifest;
mod dockerfile;
+mod metrics;
use crate::parser::*;
use crate::version::{VersionPattern, CompareOutcome};
@@ -28,7 +29,8 @@ Search FILE for docker images and suggest updates
Options:
--auth <REGISTRY> <USER> <PASS> Authenticate against REGISTRY (repeatable)
--config <PATH> Read config from PATH
---scratch <DIR> Store temporary files in DIR",
+--scratch <DIR> Store temporary files in DIR
+--metrics-port <PORT> Serve Prometheus metrics on PORT (default: disabled)",
cmd
);
}
@@ -172,36 +174,56 @@ fn perform_registry_request(registry: &str, url: &str, accept: &'static str, aut
request = state.add_to_request(request);
}
+ let start_time = std::time::Instant::now();
let response = request.call().unwrap();
+ let duration = start_time.elapsed().as_secs_f64();
+ metrics::get().http_request_duration.with_label_values(&[registry]).observe(duration);
if response.status() == 401 {
if !authentication_retry {
if let Some(ref mut state) = state {
- state.authenticate(&response).map_err(RegistryError::Other)?;
- authentication_retry = true;
- continue;
+ match state.authenticate(&response) {
+ Ok(()) => {
+ metrics::get().auth_attempts.with_label_values(&[registry, "success"]).inc();
+ authentication_retry = true;
+ continue;
+ }
+ Err(e) => {
+ metrics::get().auth_attempts.with_label_values(&[registry, "failure"]).inc();
+ return Err(RegistryError::Other(e));
+ }
+ }
} else {
+ metrics::get().http_request.with_label_values(&[registry, "error"]).inc();
return Err(RegistryError::Other(format!("Server {} returned 401 but we have no credentials", registry)));
}
} else {
+ metrics::get().auth_attempts.with_label_values(&[registry, "failure"]).inc();
+ metrics::get().http_request.with_label_values(&[registry, "error"]).inc();
return Err(RegistryError::Other(format!("Authentication failed")));
}
}
if response.status() == 429 {
+ metrics::get().rate_limits.with_label_values(&[registry]).inc();
+ metrics::get().state.set(metrics::STATE_THROTTLED);
println!("Too many requests");
std::thread::sleep(std::time::Duration::from_secs(8));
+ metrics::get().state.set(metrics::STATE_ACTIVE);
continue;
}
if response.status() == 404 {
+ metrics::get().http_request.with_label_values(&[registry, "not_found"]).inc();
return Err(RegistryError::NotFound);
}
if response.status() != 200 {
+ metrics::get().http_request.with_label_values(&[registry, "error"]).inc();
return Err(RegistryError::Other(format!("Unexpected status code: {}", response.status())));
}
+ metrics::get().http_request.with_label_values(&[registry, "success"]).inc();
return Ok(response);
}
}
@@ -227,6 +249,8 @@ fn update_images(db: &Connection, file: &str, auth: &mut Auth, img: DockerRef, e
let mut tag = img.tag.as_ref().map(|x| file[x.clone()].to_string());
let image = &file[img.image.clone()];
+ metrics::get().images_checked.with_label_values(&[registry]).inc();
+
let _last_checked = match db.query_one("
SELECT last_checked FROM images
WHERE registry = ?1 AND image = ?2
@@ -301,6 +325,7 @@ fn update_images(db: &Connection, file: &str, auth: &mut Auth, img: DockerRef, e
position: img.tag.unwrap(),
content: new_tag,
});
+ metrics::get().images_updated.with_label_values(&[registry]).inc();
}
}
@@ -323,6 +348,7 @@ fn update_images(db: &Connection, file: &str, auth: &mut Auth, img: DockerRef, e
position: img.digest.unwrap(),
content: digest_string,
});
+ metrics::get().images_updated.with_label_values(&[registry]).inc();
}
}
@@ -596,9 +622,11 @@ fn run_tool(db: &Connection, auth: &mut Auth, repos: &Vec<Repository>, mut infil
let exit = gitcmd.status()
.expect("Git command failed");
if !exit.success() {
+ metrics::get().git_operations.with_label_values(&["commit", "failure"]).inc();
println!("Commit failed, presumably there were no changes");
continue;
}
+ metrics::get().git_operations.with_label_values(&["commit", "success"]).inc();
let mut gitcmd = std::process::Command::new("git");
gitcmd
@@ -615,8 +643,10 @@ fn run_tool(db: &Connection, auth: &mut Auth, repos: &Vec<Repository>, mut infil
let exit = gitcmd.status()
.expect("Git command failed");
if !exit.success() {
+ metrics::get().git_operations.with_label_values(&["push", "failure"]).inc();
panic!("Git exited with failure");
}
+ metrics::get().git_operations.with_label_values(&["push", "success"]).inc();
}
}
@@ -631,6 +661,7 @@ fn main() {
let mut config_path = None;
let mut scratch_path = None;
let mut continuous = false;
+ let mut metrics_port: Option<u16> = None;
loop {
match it.next().map(|x| x.as_str()) {
@@ -668,6 +699,22 @@ fn main() {
Some("--continuous") => {
continuous = true;
},
+ Some("--metrics-port") => {
+ if let Some(port_str) = it.next() {
+ match port_str.parse::<u16>() {
+ Ok(port) => metrics_port = Some(port),
+ Err(_) => {
+ println!("Error: --metrics-port requires a valid port number");
+ help(cmd);
+ std::process::exit(1);
+ }
+ }
+ } else {
+ println!("Error: --metrics-port requires a parameter");
+ help(cmd);
+ std::process::exit(1);
+ }
+ },
Some("-i") | Some("--inplace") => {
overwrite = true;
},
@@ -936,8 +983,22 @@ fn main() {
let mut auth = Auth::new(auths);
+ metrics::init();
+ metrics::get().state.set(metrics::STATE_IDLE);
+ if let Some(port) = metrics_port {
+ metrics::serve(port);
+ }
+
loop {
+ metrics::get().state.set(metrics::STATE_ACTIVE);
+ let run_start = std::time::Instant::now();
+
run_tool(&db, &mut auth, &repos, infile_paths.clone(), overwrite);
+
+ let run_duration = run_start.elapsed().as_secs_f64();
+ metrics::get().run_duration.set(run_duration);
+ metrics::get().state.set(metrics::STATE_IDLE);
+
if !continuous { break; }
println!("Waiting for next run");
diff --git a/src/metrics.rs b/src/metrics.rs
new file mode 100644
index 0000000..09617c4
--- /dev/null
+++ b/src/metrics.rs
@@ -0,0 +1,147 @@
+use prometheus::{
+ CounterVec, Gauge, HistogramVec,
+ Opts, Registry, TextEncoder, Encoder,
+ histogram_opts,
+};
+use std::sync::OnceLock;
+use std::thread;
+
+pub const STATE_IDLE: f64 = 0.0;
+pub const STATE_ACTIVE: f64 = 1.0;
+pub const STATE_THROTTLED: f64 = 2.0;
+
+pub static METRICS: OnceLock<Metrics> = OnceLock::new();
+
+pub fn get() -> &'static Metrics {
+ METRICS.get().unwrap()
+}
+
+pub struct Metrics {
+ pub state: Gauge,
+ pub images_checked: CounterVec,
+ pub images_updated: CounterVec,
+ pub http_request: CounterVec,
+ pub http_request_duration: HistogramVec,
+ pub rate_limits: CounterVec,
+ pub auth_attempts: CounterVec,
+ pub run_duration: Gauge,
+ pub git_operations: CounterVec,
+ registry: Registry,
+}
+
+impl Metrics {
+ pub fn new() -> Self {
+ let registry = Registry::new();
+
+ let state = Gauge::with_opts(
+ Opts::new("vbump_state", "Current state: 0=idle, 1=active, 2=throttled"),
+ ).unwrap();
+ registry.register(Box::new(state.clone())).unwrap();
+
+ let images_checked = CounterVec::new(
+ Opts::new("vbump_images_checked_total", "Total images checked"),
+ &["registry"],
+ ).unwrap();
+ registry.register(Box::new(images_checked.clone())).unwrap();
+
+ let images_updated = CounterVec::new(
+ Opts::new("vbump_images_updated_total", "Total images with newer versions found"),
+ &["registry"],
+ ).unwrap();
+ registry.register(Box::new(images_updated.clone())).unwrap();
+
+ let http_request = CounterVec::new(
+ Opts::new("vbump_http_request_total", "Total HTTP requests to registries"),
+ &["registry", "outcome"],
+ ).unwrap();
+ registry.register(Box::new(http_request.clone())).unwrap();
+
+ let http_request_duration = HistogramVec::new(
+ histogram_opts!(
+ "vbump_http_request_duration_seconds",
+ "Request latency distribution",
+ vec![0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0]
+ ),
+ &["registry"],
+ ).unwrap();
+ registry.register(Box::new(http_request_duration.clone())).unwrap();
+
+ let rate_limits = CounterVec::new(
+ Opts::new("vbump_rate_limits_total", "Rate limit (429) responses received"),
+ &["registry"],
+ ).unwrap();
+ registry.register(Box::new(rate_limits.clone())).unwrap();
+
+ let auth_attempts = CounterVec::new(
+ Opts::new("vbump_auth_attempts_total", "Auth attempts (success/failure)"),
+ &["registry", "outcome"],
+ ).unwrap();
+ registry.register(Box::new(auth_attempts.clone())).unwrap();
+
+ let run_duration = Gauge::with_opts(
+ Opts::new("vbump_run_duration_seconds", "Duration of last complete scan"),
+ ).unwrap();
+ registry.register(Box::new(run_duration.clone())).unwrap();
+
+ let git_operations = CounterVec::new(
+ Opts::new("vbump_git_operations_total", "Git commits/pushes (success/failure)"),
+ &["operation", "outcome"],
+ ).unwrap();
+ registry.register(Box::new(git_operations.clone())).unwrap();
+
+ Self {
+ state,
+ images_checked,
+ images_updated,
+ http_request,
+ http_request_duration,
+ rate_limits,
+ auth_attempts,
+ run_duration,
+ git_operations,
+ registry,
+ }
+ }
+}
+
+pub fn init() {
+ METRICS.get_or_init(Metrics::new);
+}
+
+pub fn serve(port: u16) {
+ let registry = get().registry.clone();
+
+ thread::spawn(move || {
+ let addr = format!("0.0.0.0:{}", port);
+ let server = match tiny_http::Server::http(&addr) {
+ Ok(s) => s,
+ Err(e) => {
+ eprintln!("Failed to start metrics server on {}: {}", addr, e);
+ return;
+ }
+ };
+ println!("Metrics server listening on {}", addr);
+
+ for request in server.incoming_requests() {
+ let response = if request.url() == "/metrics" {
+ let encoder = TextEncoder::new();
+ let metric_families = registry.gather();
+ let mut buffer = Vec::new();
+ encoder.encode(&metric_families, &mut buffer).unwrap();
+
+ tiny_http::Response::from_data(buffer)
+ .with_header(
+ tiny_http::Header::from_bytes(
+ &b"Content-Type"[..],
+ &b"text/plain; version=0.0.4; charset=utf-8"[..],
+ ).unwrap()
+ )
+ } else {
+ tiny_http::Response::from_string("Not Found")
+ .with_status_code(404)
+ };
+
+ let _ = request.respond(response);
+ }
+ });
+}