Add health endpoint for agent monitoring

Added /health HTTP endpoint on port 8080 that returns:
- Agent status and version
- Uptime in seconds
- Pending metrics count
- Last error (if any)

Implementation:
- Uses lightweight tiny_http server in background thread
- Non-blocking health checks
- Returns JSON for easy integration with monitoring tools
- Ready for Kubernetes liveness/readiness probes

Example response:
{
  "status": "healthy",
  "version": "0.1.0",
  "uptime_seconds": 3600,
  "config_last_fetch": "2026-01-14T23:00:00Z",
  "metrics_pending": 0,
  "last_error": null
}
This commit is contained in:
Graham McIntire 2026-01-14 18:46:25 -06:00
parent 824f4388eb
commit f7ac5f48e8
No known key found for this signature in database
4 changed files with 167 additions and 0 deletions

31
Cargo.lock generated
View file

@ -85,6 +85,12 @@ version = "1.0.100"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
[[package]]
name = "ascii"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16"
[[package]]
name = "base64"
version = "0.22.1"
@ -119,6 +125,12 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "chunked_transfer"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901"
[[package]]
name = "clap"
version = "4.5.54"
@ -309,6 +321,12 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "httpdate"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "icu_collections"
version = "2.1.1"
@ -852,6 +870,18 @@ dependencies = [
"windows-sys 0.61.2",
]
[[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 = "tinystr"
version = "0.8.2"
@ -896,6 +926,7 @@ dependencies = [
"serde",
"serde_json",
"snmp",
"tiny_http",
"tokio",
"ureq",
]

View file

@ -14,6 +14,7 @@ log = { version = "0.4", features = ["std"] }
clap = { version = "4.0", features = ["derive", "env"] }
prost = "0.13"
prost-types = "0.13"
tiny_http = "0.12"
[build-dependencies]
prost-build = "0.13"

130
src/health.rs Normal file
View file

@ -0,0 +1,130 @@
use crate::buffer::Storage;
use crate::metrics::Timestamp;
use log::{error, info};
use serde::{Deserialize, Serialize};
use std::sync::{Arc, Mutex};
use std::thread;
use tiny_http::{Response, Server};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HealthStatus {
pub status: String,
pub version: String,
pub uptime_seconds: u64,
pub config_last_fetch: Option<String>,
pub metrics_pending: usize,
pub last_error: Option<String>,
}
pub struct HealthServer {
start_time: Timestamp,
storage: Storage,
last_error: Arc<Mutex<Option<String>>>,
config_last_fetch: Arc<Mutex<Option<Timestamp>>>,
}
impl HealthServer {
pub fn new(storage: Storage) -> Self {
Self {
start_time: Timestamp::now(),
storage,
last_error: Arc::new(Mutex::new(None)),
config_last_fetch: Arc::new(Mutex::new(None)),
}
}
pub fn update_config_fetch_time(&self) {
if let Ok(mut last_fetch) = self.config_last_fetch.lock() {
*last_fetch = Some(Timestamp::now());
}
}
pub fn record_error(&self, error: String) {
if let Ok(mut last_error) = self.last_error.lock() {
*last_error = Some(error);
}
}
pub fn start(self, port: u16) {
thread::spawn(move || {
let addr = format!("0.0.0.0:{}", port);
info!("Starting health endpoint on {}", addr);
let server = match Server::http(&addr) {
Ok(s) => s,
Err(e) => {
error!("Failed to start health server: {}", e);
return;
}
};
for request in server.incoming_requests() {
let path = request.url();
if path == "/health" {
let status = self.get_health_status();
let json = match serde_json::to_string(&status) {
Ok(j) => j,
Err(e) => {
error!("Failed to serialize health status: {}", e);
let _ = request.respond(
Response::from_string("Internal error").with_status_code(500),
);
continue;
}
};
let response = Response::from_string(json)
.with_header(
tiny_http::Header::from_bytes(
&b"Content-Type"[..],
&b"application/json"[..],
)
.unwrap(),
)
.with_status_code(200);
let _ = request.respond(response);
} else {
let _ =
request.respond(Response::from_string("Not found").with_status_code(404));
}
}
});
}
fn get_health_status(&self) -> HealthStatus {
let uptime = self.start_time.elapsed_secs() as u64;
// Get pending metrics count
let metrics_pending = self
.storage
.get_pending_metrics(1000)
.map(|m| m.len())
.unwrap_or(0);
// Get last config fetch time
let config_last_fetch = self
.config_last_fetch
.lock()
.ok()
.and_then(|guard| *guard)
.map(|ts| ts.to_rfc3339());
// Get last error
let last_error = self
.last_error
.lock()
.ok()
.and_then(|guard| (*guard).clone());
HealthStatus {
status: "healthy".to_string(),
version: env!("CARGO_PKG_VERSION").to_string(),
uptime_seconds: uptime,
config_last_fetch,
metrics_pending,
last_error,
}
}
}

View file

@ -1,6 +1,7 @@
mod api_client;
mod buffer;
mod config;
mod health;
mod metrics;
mod poller;
mod proto;
@ -100,6 +101,10 @@ async fn main() {
}
};
// Start health endpoint server
let health_server = health::HealthServer::new(storage.clone());
health_server.start(8080);
let snmp_client = snmp::SnmpClient::new();
// Create and run scheduler