pub mod exposition;
mod config;
mod feed;
mod grammar;
pub mod samples;
#[cfg(feature = "otel")]
mod otlp;
pub use config::MetricsConfig;
use std::sync::Arc;
use std::time::Duration;
use shep_client::ReconnectingClient;
use shep_client::RequestError;
use shep_client::dogs::Stop;
use shep_core::protocol::ProcessInfo;
use tokio::net::{TcpListener, TcpStream};
use super::DogRuntime;
use crate::exit::ExitCode;
use crate::host_sample::{HostReading, sample_host_off_worker};
use crate::http::{self, HttpError};
use samples::{SampleFamily, SampleStore};
const READ_TIMEOUT: Duration = Duration::from_secs(5);
#[cfg(not(feature = "otel"))]
const OTLP_FEATURE_REFUSAL: &str = concat!(
"`otlp_endpoint` is set, but this build has no `otel` feature and exports nothing; ",
"install it with `cargo install shep --features otel`",
);
#[derive(Debug, Default)]
pub struct Reading {
pub flock: Vec<ProcessInfo>,
pub daemon_version: String,
pub daemon_pid: u32,
pub host: Option<HostReading>,
pub samples: Vec<SampleFamily>,
}
pub(crate) async fn collect_reading(
client: &ReconnectingClient,
store: &SampleStore,
) -> Result<Reading, RequestError> {
let flock = client.list_flock().await?;
let samples = store.snapshot(&flock);
let ack = client.daemon();
Ok(Reading {
flock,
daemon_version: ack.daemon_version,
daemon_pid: ack.pid,
samples,
host: sample_host_off_worker().await,
})
}
pub async fn run(runtime: DogRuntime) -> ExitCode {
let config = match runtime.config::<MetricsConfig>() {
Ok(config) => config,
Err(err) => {
eprintln!("shep dog metrics: {err}; see `shep dogs`");
return ExitCode::InvalidConfig;
}
};
#[cfg(not(feature = "otel"))]
if config.otlp_endpoint.is_some() {
eprintln!("shep dog metrics: {OTLP_FEATURE_REFUSAL}");
return ExitCode::InvalidConfig;
}
#[cfg(feature = "otel")]
let exporter = match otlp::exporter_for(&config) {
Ok(exporter) => exporter,
Err(err) => {
eprintln!("shep dog metrics: {err}");
return ExitCode::InvalidConfig;
}
};
let listener = match TcpListener::bind(config.bind).await {
Ok(listener) => listener,
Err(err) => {
eprintln!("shep dog metrics: could not bind {}: {err}", config.bind);
return ExitCode::Failure;
}
};
let mut stop = Stop::on_stop_signals();
let client = Arc::new(runtime.into_client());
let store = Arc::new(samples::SampleStore::new());
#[cfg(feature = "otel")]
if let Some(exporter) = exporter {
let _exporter_task = otlp::spawn(exporter, Arc::clone(&client), Arc::clone(&store));
}
tokio::select! {
() = stop.wait() => ExitCode::Success,
() = accept_forever(listener, Arc::clone(&client), Arc::clone(&store)) => ExitCode::Success,
lost = client.link_lost(super::SHEPHERD_RETURN_BUDGET) => {
eprintln!("shep dog metrics: {lost}");
ExitCode::from(&lost)
}
code = feed::run(Arc::clone(&client), Arc::clone(&store)) => code,
}
}
async fn accept_forever(
listener: TcpListener,
client: Arc<ReconnectingClient>,
store: Arc<SampleStore>,
) {
loop {
match listener.accept().await {
Ok((stream, _peer)) => {
let client = Arc::clone(&client);
let store = Arc::clone(&store);
tokio::spawn(handle_connection(stream, client, store));
}
Err(err) => {
eprintln!("shep dog metrics: accept failed: {err}");
}
}
}
}
async fn handle_connection(
mut stream: TcpStream,
client: Arc<ReconnectingClient>,
store: Arc<SampleStore>,
) {
let request = match http::read_request(&mut stream, READ_TIMEOUT).await {
Ok(request) => request,
Err(_err) => return,
};
let path = request
.target
.split_once('?')
.map_or(request.target.as_str(), |(path, _query)| path);
if path != "/metrics" {
let _: Result<(), HttpError> = http::write_response(
&mut stream,
404,
"text/plain",
b"not found; metrics are served at /metrics\n",
)
.await;
return;
}
let reading = match collect_reading(&client, &store).await {
Ok(reading) => reading,
Err(_) => {
let _: Result<(), HttpError> = http::write_response(
&mut stream,
503,
"text/plain",
b"the shepherd did not answer\n",
)
.await;
return;
}
};
let body = exposition::render(&reading);
let _: Result<(), HttpError> = http::write_response(
&mut stream,
200,
"text/plain; version=0.0.4",
body.as_bytes(),
)
.await;
}
#[cfg(test)]
mod tests {
use std::net::SocketAddr;
use std::time::Duration;
use shep_client::testing::{
Handshake, fake_daemon_across_handovers, fake_reconnecting_client_on, sample_ack,
};
use shep_core::protocol::{HelloAck, PROTOCOL_VERSION, ProcessInfo};
use shep_core::status::ProcStatus;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::task::JoinHandle;
use super::*;
pub(super) fn sample_info(name: &str) -> ProcessInfo {
ProcessInfo::builder(1, name, ProcStatus::Online)
.pid(Some(4242))
.uptime_ms(1_000)
.cpu_percent(Some(0.5))
.memory_bytes(Some(1024))
.build()
}
mod slow {
use super::*;
async fn free_port() -> u16 {
let probe = TcpListener::bind(("127.0.0.1", 0)).await.unwrap();
probe.local_addr().unwrap().port()
}
#[tokio::test]
async fn a_metrics_dog_whose_shepherd_is_gone_waits_its_budget_and_then_exits() {
let dir = tempfile::tempdir().unwrap();
let socket = shep_client::testing::control_address(dir.path());
let port = free_port().await;
let shepherds =
fake_daemon_across_handovers(&socket, vec![Handshake::Accept(sample_ack())]);
shepherds.reply_to_dog_config(&format!("bind = \"127.0.0.1:{port}\"\n"));
shepherds.reply_to_list(vec![sample_info("web")]);
let paths = crate::dog::tests::test_paths(dir.path(), socket);
let dog = tokio::spawn(crate::dog::run_dog("metrics", paths));
let addr = SocketAddr::from(([127, 0, 0, 1], port));
let bound = tokio::time::timeout(Duration::from_secs(10), async {
while TcpStream::connect(addr).await.is_err() {
assert!(!dog.is_finished(), "the dog exited before it bound");
tokio::time::sleep(Duration::from_millis(20)).await;
}
})
.await;
assert!(bound.is_ok(), "the dog never bound its port");
let served = scrape(addr, "/metrics").await;
assert!(
served.starts_with("HTTP/1.1 200 "),
"the dog must be answering from the shepherd's own flock: {served}"
);
assert!(served.contains("web"), "{served}");
drop(shepherds);
let went = tokio::time::Instant::now();
let code = tokio::time::timeout(crate::dog::SHEPHERD_RETURN_BUDGET * 3, dog)
.await
.expect("a dog whose shepherd is gone must exit, not linger")
.unwrap();
let waited = went.elapsed();
assert_eq!(
code,
ExitCode::DaemonUnreachable,
"exiting 0 would read as a dog that finished its work"
);
let budget = crate::dog::SHEPHERD_RETURN_BUDGET;
assert!(
waited >= budget,
"gave up after {waited:?}, inside the {budget:?} a handover \
is allowed to take, which is the restart-per-reload this rule exists to avoid"
);
}
}
pub(super) struct RunningDog {
addr: SocketAddr,
handle: JoinHandle<()>,
}
impl RunningDog {
pub(super) fn addr(&self) -> SocketAddr {
self.addr
}
}
impl Drop for RunningDog {
fn drop(&mut self) {
self.handle.abort();
}
}
pub(super) async fn serve_on_free_port(
client: ReconnectingClient,
config: MetricsConfig,
) -> RunningDog {
let listener = TcpListener::bind(config.bind).await.unwrap();
let addr = listener.local_addr().unwrap();
let handle = tokio::spawn(accept_forever(
listener,
Arc::new(client),
Arc::new(samples::SampleStore::new()),
));
RunningDog { addr, handle }
}
pub(super) async fn scrape(addr: SocketAddr, path: &str) -> String {
let mut stream = tokio::time::timeout(Duration::from_secs(5), TcpStream::connect(addr))
.await
.expect("connect must not hang")
.unwrap();
let request = format!("GET {path} HTTP/1.1\r\nHost: localhost\r\n\r\n");
tokio::time::timeout(Duration::from_secs(5), stream.write_all(request.as_bytes()))
.await
.expect("write must not hang")
.unwrap();
let mut buf = Vec::new();
tokio::time::timeout(Duration::from_secs(5), stream.read_to_end(&mut buf))
.await
.expect("read must not hang")
.unwrap();
String::from_utf8(buf).expect("the exposition is ASCII/UTF-8")
}
#[tokio::test]
async fn every_scrape_asks_the_shepherd_again() {
let dir = tempfile::tempdir().unwrap();
let socket = shep_client::testing::control_address(dir.path());
let (client, daemon) = fake_reconnecting_client_on(&socket).await;
daemon.reply_to_list_sequence(vec![
vec![sample_info("web")],
vec![sample_info("web"), sample_info("api")],
]);
let dog = serve_on_free_port(client, MetricsConfig::default_on_port(0)).await;
let first = scrape(dog.addr(), "/metrics").await;
assert!(first.contains(r#"sheep="web""#), "{first}");
assert!(!first.contains(r#"sheep="api""#), "{first}");
let second = scrape(dog.addr(), "/metrics").await;
assert!(
second.contains(r#"sheep="api""#),
"the second scrape must see the second listing: {second}"
);
assert_eq!(daemon.list_flock_count(), 2, "one ListFlock per scrape");
}
#[tokio::test]
async fn a_shepherd_that_will_not_answer_produces_a_503() {
let dir = tempfile::tempdir().unwrap();
let socket = shep_client::testing::control_address(dir.path());
let shepherds =
fake_daemon_across_handovers(&socket, vec![Handshake::Accept(sample_ack())]);
let client = ReconnectingClient::connect(&socket).await.unwrap();
let dog = serve_on_free_port(client, MetricsConfig::default_on_port(0)).await;
shepherds.cut_on_next_request();
let response = scrape(dog.addr(), "/metrics").await;
assert!(response.starts_with("HTTP/1.1 503 "), "{response}");
}
#[tokio::test]
async fn a_scrape_after_a_handover_serves_the_successors_flock() {
let dir = tempfile::tempdir().unwrap();
let socket = shep_client::testing::control_address(dir.path());
let successor = HelloAck {
daemon_version: "0.2.0".into(),
protocol: PROTOCOL_VERSION,
pid: 5150,
min_supported: None,
};
let shepherds = fake_daemon_across_handovers(
&socket,
vec![
Handshake::Accept(sample_ack()),
Handshake::Accept(successor),
],
);
shepherds.reply_to_list(vec![sample_info("web")]);
let client = ReconnectingClient::connect(&socket).await.unwrap();
let dog = serve_on_free_port(client, MetricsConfig::default_on_port(0)).await;
let before = scrape(dog.addr(), "/metrics").await;
assert!(before.starts_with("HTTP/1.1 200 "), "{before}");
assert!(
before.contains(r#"shep_daemon_up{version="9.9.9"}"#),
"{before}"
);
shepherds.cut().await;
let recovered = tokio::time::timeout(Duration::from_secs(5), async {
loop {
let body = scrape(dog.addr(), "/metrics").await;
if body.starts_with("HTTP/1.1 200 ") {
return body;
}
tokio::task::yield_now().await;
}
})
.await
.expect("the dog must answer 200 again after its shepherd is replaced");
assert!(
recovered.contains(r#"sheep="web""#),
"the exposition must carry the successor's flock: {recovered}"
);
assert!(
recovered.contains(r#"shep_daemon_up{version="0.2.0"}"#),
"the exposition must name the daemon now running, not the one that was replaced: {recovered}"
);
}
#[tokio::test]
async fn only_the_metrics_path_serves_metrics() {
let dir = tempfile::tempdir().unwrap();
let socket = shep_client::testing::control_address(dir.path());
let (client, _daemon) = fake_reconnecting_client_on(&socket).await;
let dog = serve_on_free_port(client, MetricsConfig::default_on_port(0)).await;
let root = scrape(dog.addr(), "/").await;
assert!(root.starts_with("HTTP/1.1 404 "), "{root}");
assert!(root.contains("/metrics"), "{root}");
let metrics = scrape(dog.addr(), "/metrics").await;
assert!(metrics.starts_with("HTTP/1.1 200 "), "{metrics}");
}
#[tokio::test]
async fn a_scrape_carries_the_host_sample() {
let dir = tempfile::tempdir().unwrap();
let socket = shep_client::testing::control_address(dir.path());
let (client, daemon) = fake_reconnecting_client_on(&socket).await;
daemon.reply_to_list_sequence(vec![vec![sample_info("web")]]);
let dog = serve_on_free_port(client, MetricsConfig::default_on_port(0)).await;
let body = scrape(dog.addr(), "/metrics").await;
assert!(
body.contains("shep_host_memory_total_bytes"),
"the host sample must survive the trip to the blocking pool: {body}"
);
assert!(
body.contains("shep_host_uptime_seconds"),
"uptime is part of the same sample: {body}"
);
assert_eq!(
daemon.list_flock_count(),
1,
"one ListFlock for the one scrape"
);
}
}
#[cfg(all(test, not(feature = "otel")))]
mod otlp_off_tests {
use std::time::Duration;
use shep_client::testing::{sample_ack, serve_one_request};
use shep_core::protocol::{Request, Response};
use crate::exit::ExitCode;
#[tokio::test]
async fn an_endpoint_without_the_feature_refuses_to_start() {
let dir = tempfile::tempdir().unwrap();
let socket = shep_client::testing::control_address(dir.path());
let response = Response::DogSection {
toml: "otlp_endpoint = \"http://127.0.0.1:4318\"\n"
.to_string()
.into(),
};
let handle = serve_one_request(&socket, sample_ack(), response).await;
let paths = crate::dog::tests::test_paths(dir.path(), socket);
let dog = tokio::spawn(crate::dog::run_dog("metrics", paths));
let envelope = tokio::time::timeout(Duration::from_secs(10), handle)
.await
.expect("the dog must reach the wire")
.unwrap();
assert_eq!(
envelope.body,
Request::DogConfigResolved {
name: "metrics".to_string()
}
);
let code = tokio::time::timeout(Duration::from_secs(10), dog)
.await
.expect("a refused config is an exit, not a hang")
.unwrap();
assert_eq!(code, ExitCode::InvalidConfig);
}
}