pub mod encode;
mod reply;
use std::fmt;
use std::sync::Arc;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use shep_client::ReconnectingClient;
use shep_client::RequestError;
use tokio::task::JoinHandle;
use super::MetricsConfig;
use crate::fetch;
pub use encode::{ExportContext, encode};
use reply::rejection;
const METRICS_PATH: &str = "/v1/metrics";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum OtlpConfigError {
Url(String),
IntervalZero,
TimeoutZero,
BadHeaderKey(String),
BadHeaderValue(String),
}
impl fmt::Display for OtlpConfigError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Url(reason) => write!(f, "otlp_endpoint is not usable: {reason}"),
Self::IntervalZero => f.write_str("otlp_interval must be more than 0"),
Self::TimeoutZero => f.write_str("otlp_timeout must be more than 0"),
Self::BadHeaderKey(key) => {
write!(
f,
"otlp_headers key {key:?} is empty or carries a control byte"
)
}
Self::BadHeaderValue(key) => {
write!(
f,
"otlp_headers value for key {key:?} carries a control byte"
)
}
}
}
}
impl core::error::Error for OtlpConfigError {}
#[derive(Debug)]
enum ExportError {
Collect(RequestError),
Send(fetch::FetchError),
Partial(String),
}
impl fmt::Display for ExportError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Collect(source) => write!(f, "no reading to export: {source}"),
Self::Send(source) => write!(f, "{source}"),
Self::Partial(reason) => {
write!(f, "the collector took less than it was sent: {reason}")
}
}
}
}
impl core::error::Error for ExportError {
fn source(&self) -> Option<&(dyn core::error::Error + 'static)> {
match self {
Self::Collect(source) => Some(source),
Self::Send(source) => Some(source),
Self::Partial(_reason) => None,
}
}
}
pub struct Exporter {
target: fetch::Target,
interval: Duration,
timeout: Duration,
headers: Vec<(String, String)>,
started_unix_nano: u64,
host_name: Option<String>,
}
impl fmt::Debug for Exporter {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"Exporter {{ target: {:?}, interval: {:?}, timeout: {:?}, headers: <redacted>, \
started_unix_nano: {}, host_name: {:?} }}",
self.target, self.interval, self.timeout, self.started_unix_nano, self.host_name,
)
}
}
pub fn exporter_for(config: &MetricsConfig) -> Result<Option<Exporter>, OtlpConfigError> {
let Some(endpoint) = &config.otlp_endpoint else {
return Ok(None);
};
let url = metrics_url(endpoint);
let target = match fetch::parse_url(&url) {
Ok(target) => target,
Err(fetch::FetchError::Url(reason)) => return Err(OtlpConfigError::Url(reason)),
Err(source) => return Err(OtlpConfigError::Url(source.to_string())),
};
let interval = config.otlp_interval.as_duration();
if interval.is_zero() {
return Err(OtlpConfigError::IntervalZero);
}
let timeout = config.otlp_timeout.as_duration();
if timeout.is_zero() {
return Err(OtlpConfigError::TimeoutZero);
}
let mut headers = Vec::with_capacity(config.otlp_headers.len());
for (key, value) in &config.otlp_headers {
if key.is_empty() || has_control_byte(key) {
return Err(OtlpConfigError::BadHeaderKey(key.clone()));
}
if has_control_byte(value) {
return Err(OtlpConfigError::BadHeaderValue(key.clone()));
}
headers.push((key.clone(), value.clone()));
}
Ok(Some(Exporter {
target,
interval,
timeout,
headers,
started_unix_nano: unix_nanos(),
host_name: sysinfo::System::host_name(),
}))
}
fn has_control_byte(value: &str) -> bool {
value.chars().any(char::is_control)
}
fn metrics_url(endpoint: &str) -> String {
let (base, query) = match endpoint.split_once('?') {
Some((head, tail)) => (head.trim_end_matches('/'), Some(tail)),
None => (endpoint.trim_end_matches('/'), None),
};
match query {
Some(query) => format!("{base}{METRICS_PATH}?{query}"),
None => format!("{base}{METRICS_PATH}"),
}
}
fn unix_nanos() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
.try_into()
.unwrap_or(u64::MAX)
}
pub fn spawn(
exporter: Exporter,
client: Arc<ReconnectingClient>,
store: Arc<super::samples::SampleStore>,
) -> JoinHandle<()> {
tokio::spawn(export_loop(exporter, client, store))
}
async fn export_loop(
exporter: Exporter,
client: Arc<ReconnectingClient>,
store: Arc<super::samples::SampleStore>,
) {
let mut ticker = tokio::time::interval(exporter.interval);
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
ticker.tick().await;
let mut healthy = true;
loop {
ticker.tick().await;
match exporter.export(&client, &store).await {
Ok(()) => {
if !healthy {
eprintln!("shep dog metrics: otlp export recovered");
healthy = true;
}
}
Err(err) => {
if healthy {
eprintln!("shep dog metrics: otlp export failed: {err}");
healthy = false;
}
}
}
}
}
impl Exporter {
async fn export(
&self,
client: &ReconnectingClient,
store: &super::samples::SampleStore,
) -> Result<(), ExportError> {
let reading = super::collect_reading(client, store)
.await
.map_err(ExportError::Collect)?;
let context = ExportContext {
now_unix_nano: unix_nanos(),
exporter_start_unix_nano: self.started_unix_nano,
host_name: self.host_name.as_deref(),
};
let body = encode(&reading, &context);
let reply = fetch::post_json(&self.target, &self.headers, body.as_bytes(), self.timeout)
.await
.map_err(ExportError::Send)?;
match rejection(&reply.body) {
Some(reason) => Err(ExportError::Partial(reason)),
None => Ok(()),
}
}
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use std::net::SocketAddr;
use std::sync::Mutex;
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
use shep_client::testing::fake_reconnecting_client_on;
use crate::dog::metrics::accept_forever;
use crate::dog::metrics::samples::SampleStore;
use super::super::HostReading;
use super::super::{Reading, tests as metrics_tests};
use super::*;
struct Collector {
addr: SocketAddr,
seen: Arc<Mutex<Vec<String>>>,
handle: JoinHandle<()>,
}
impl Collector {
fn seen(&self) -> Vec<String> {
self.seen.lock().unwrap().clone()
}
async fn wait_for(&self, want: usize) -> Vec<String> {
let budget = tokio::time::Instant::now() + Duration::from_secs(10);
loop {
let seen = self.seen();
if seen.len() >= want {
return seen;
}
assert!(
tokio::time::Instant::now() < budget,
"the collector answered {} of {want} requests",
seen.len()
);
tokio::time::sleep(Duration::from_millis(20)).await;
}
}
}
impl Drop for Collector {
fn drop(&mut self) {
self.handle.abort();
}
}
async fn collector_answering(responses: Vec<String>) -> Collector {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let seen = Arc::new(Mutex::new(Vec::new()));
let recorder = Arc::clone(&seen);
let handle = tokio::spawn(async move {
let mut answered = 0;
loop {
let (stream, _peer) = listener.accept().await.unwrap();
let mut reader = tokio::io::BufReader::new(stream);
let mut request = String::new();
let mut content_length = None;
loop {
let mut line = String::new();
let read = reader.read_line(&mut line).await.unwrap_or(0);
if read == 0 {
break;
}
if let Some((_name, value)) = line.split_once(':')
&& line.to_ascii_lowercase().starts_with("content-length")
{
content_length = value.trim().parse().ok();
}
request.push_str(&line);
if line == "\r\n" || line == "\n" {
break;
}
}
if let Some(length) = content_length {
let mut body = vec![0u8; length];
if reader.read_exact(&mut body).await.is_ok() {
request.push_str(&String::from_utf8_lossy(&body));
}
}
recorder.lock().unwrap().push(request);
let response = &responses[answered % responses.len()];
answered += 1;
let _ = reader.write_all(response.as_bytes()).await;
let _ = reader.shutdown().await;
}
});
Collector { addr, seen, handle }
}
async fn collector(statuses: Vec<u16>) -> Collector {
collector_answering(
statuses
.iter()
.map(|status| {
format!(
"HTTP/1.1 {status} ok\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
)
})
.collect(),
)
.await
}
fn exporter_on(addr: SocketAddr, cadence: Duration) -> Exporter {
Exporter {
target: fetch::parse_url(&format!("http://{}:{}/v1/metrics", addr.ip(), addr.port()))
.unwrap(),
interval: cadence,
timeout: Duration::from_secs(5),
headers: vec![("Authorization".to_owned(), "Bearer s3cr3t".to_owned())],
started_unix_nano: 1_699_999_900_000_000_000,
host_name: Some("sheepbox".to_owned()),
}
}
fn reading() -> Reading {
Reading {
flock: vec![],
daemon_version: "9.9.9".to_string(),
daemon_pid: 12345,
samples: vec![],
host: Some(HostReading {
memory_total_bytes: 16_000_000_000,
memory_used_bytes: 8_000_000_000,
processes: 200,
uptime_seconds: 3600,
}),
}
}
#[tokio::test]
async fn a_post_carries_the_headers_the_path_and_the_json_content_type() {
let collector = collector(vec![200]).await;
let exporter = exporter_on(collector.addr, Duration::from_secs(60));
let body = serde_json::to_string(&serde_json::json!({"resourceMetrics": []})).unwrap();
fetch::post_json(
&exporter.target,
&exporter.headers,
body.as_bytes(),
Duration::from_secs(5),
)
.await
.expect("a collector that answers 200 is a success");
let request = collector.wait_for(1).await.remove(0);
assert!(
request.starts_with("POST /v1/metrics HTTP/1.1\r\n"),
"{request}"
);
assert!(
request.contains("\r\nAuthorization: Bearer s3cr3t\r\n"),
"{request}"
);
assert!(
request.contains("\r\nContent-Type: application/json\r\n"),
"{request}"
);
assert!(request.contains("\r\nConnection: close\r\n"), "{request}");
assert!(
request.contains(&format!("\r\nContent-Length: {}\r\n", body.len())),
"{request}"
);
let (_head, sent_body) = request
.split_once("\r\n\r\n")
.expect("a blank line ends the head");
assert_eq!(sent_body, body);
}
#[tokio::test]
async fn a_5xx_is_a_failed_export_and_the_next_tick_still_runs() {
let collector = collector(vec![500]).await;
let dir = tempfile::tempdir().unwrap();
let socket = shep_client::testing::control_address(dir.path());
let (client, _daemon) = fake_reconnecting_client_on(&socket).await;
let exporter = exporter_on(collector.addr, Duration::from_millis(250));
let store = SampleStore::new();
let err = exporter
.export(&client, &store)
.await
.expect_err("a collector answering 500 is a failed export");
assert!(
matches!(err, ExportError::Send(fetch::FetchError::Status(500))),
"{err:?}"
);
let handle = tokio::spawn(export_loop(
exporter,
Arc::new(client),
Arc::new(SampleStore::new()),
));
let seen = collector.wait_for(3).await;
assert_eq!(seen.len(), 3, "the tick after a failed export must run");
assert!(!handle.is_finished(), "a failing exporter must not end");
handle.abort();
}
#[tokio::test]
async fn a_2xx_naming_a_partial_success_is_a_failed_export() {
let body = r#"{"partialSuccess":{"rejectedDataPoints":"1","errorMessage":"bad point"}}"#;
let collector = collector_answering(vec![format!(
"HTTP/1.1 200 ok\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
)])
.await;
let dir = tempfile::tempdir().unwrap();
let socket = shep_client::testing::control_address(dir.path());
let (client, _daemon) = fake_reconnecting_client_on(&socket).await;
let exporter = exporter_on(collector.addr, Duration::from_secs(60));
let err = exporter
.export(&client, &SampleStore::new())
.await
.expect_err("a 2xx naming dropped points is not a clean export");
assert!(
matches!(&err, ExportError::Partial(reason) if reason == "1 data point rejected: bad point"),
"{err:?}"
);
}
#[tokio::test]
async fn a_204_with_no_content_length_is_a_clean_export() {
let collector = collector_answering(vec![
"HTTP/1.1 204 No Content\r\nConnection: close\r\n\r\n".to_string(),
])
.await;
let dir = tempfile::tempdir().unwrap();
let socket = shep_client::testing::control_address(dir.path());
let (client, _daemon) = fake_reconnecting_client_on(&socket).await;
let exporter = exporter_on(collector.addr, Duration::from_secs(60));
exporter
.export(&client, &SampleStore::new())
.await
.expect("a 204 that named nothing took everything");
assert_eq!(collector.wait_for(1).await.len(), 1);
}
#[tokio::test]
async fn a_200_echoing_an_empty_partial_success_is_a_clean_export() {
let body = r#"{"partialSuccess":{}}"#;
let collector = collector_answering(vec![format!(
"HTTP/1.1 200 ok\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
)])
.await;
let dir = tempfile::tempdir().unwrap();
let socket = shep_client::testing::control_address(dir.path());
let (client, _daemon) = fake_reconnecting_client_on(&socket).await;
let exporter = exporter_on(collector.addr, Duration::from_secs(60));
exporter
.export(&client, &SampleStore::new())
.await
.expect("an empty `partialSuccess` names nothing the collector dropped");
assert_eq!(collector.wait_for(1).await.len(), 1);
}
#[tokio::test]
async fn an_unreachable_endpoint_does_not_stop_the_scrape_server() {
let dir = tempfile::tempdir().unwrap();
let socket = shep_client::testing::control_address(dir.path());
let (client, _daemon) = fake_reconnecting_client_on(&socket).await;
let client = Arc::new(client);
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let served = listener.local_addr().unwrap();
let server = tokio::spawn(accept_forever(
listener,
Arc::clone(&client),
Arc::new(SampleStore::new()),
));
let probe = TcpListener::bind("127.0.0.1:0").await.unwrap();
let dead = probe.local_addr().unwrap();
drop(probe);
let handle = tokio::spawn(export_loop(
exporter_on(dead, Duration::from_millis(100)),
Arc::clone(&client),
Arc::new(SampleStore::new()),
));
let response = metrics_tests::scrape(served, "/metrics").await;
assert!(response.starts_with("HTTP/1.1 200 "), "{response}");
assert!(
response.contains(r#"shep_daemon_up{version="9.9.9"} 1"#),
"{response}"
);
assert!(!handle.is_finished(), "the exporter must still be trying");
assert!(
!server.is_finished(),
"the scrape server must still be accepting"
);
handle.abort();
server.abort();
}
#[tokio::test(start_paused = true)]
async fn the_first_export_lands_one_interval_after_start() {
let collector = collector(vec![200]).await;
let dir = tempfile::tempdir().unwrap();
let socket = shep_client::testing::control_address(dir.path());
let (client, _daemon) = fake_reconnecting_client_on(&socket).await;
let handle = tokio::spawn(export_loop(
exporter_on(collector.addr, Duration::from_secs(60)),
Arc::new(client),
Arc::new(SampleStore::new()),
));
tokio::time::sleep(Duration::from_secs(1)).await;
assert!(
collector.seen().is_empty(),
"an export landed inside the first interval"
);
let mut landed = false;
for _ in 0..120 {
tokio::time::sleep(Duration::from_secs(1)).await;
if !collector.seen().is_empty() {
landed = true;
break;
}
}
assert!(landed, "no export landed inside two intervals");
handle.abort();
}
#[test]
fn a_zero_interval_is_refused_rather_than_panicking_the_loop() {
let config = MetricsConfig {
otlp_endpoint: Some("http://127.0.0.1:4318".to_owned()),
otlp_interval: shep_core::values::UpDuration::from_millis(0),
..MetricsConfig::default()
};
assert_eq!(
exporter_for(&config).unwrap_err(),
OtlpConfigError::IntervalZero
);
let config = MetricsConfig {
otlp_endpoint: Some("http://127.0.0.1:4318".to_owned()),
otlp_timeout: shep_core::values::UpDuration::from_millis(0),
..MetricsConfig::default()
};
assert_eq!(
exporter_for(&config).unwrap_err(),
OtlpConfigError::TimeoutZero
);
}
#[test]
fn a_header_with_a_newline_is_refused_naming_the_key_not_the_value() {
let config = MetricsConfig {
otlp_endpoint: Some("http://127.0.0.1:4318".to_owned()),
otlp_headers: BTreeMap::from([(
"Authorization".to_owned(),
"Bearer s3cr3t\r\nX-Injected: yes".to_owned(),
)]),
..MetricsConfig::default()
};
let err = exporter_for(&config).unwrap_err();
assert_eq!(
err,
OtlpConfigError::BadHeaderValue("Authorization".to_owned())
);
let shown = format!("{err} {err:?}");
assert!(shown.contains("Authorization"), "{shown}");
assert!(!shown.contains("s3cr3t"), "{shown}");
assert!(!shown.contains("X-Injected"), "{shown}");
let config = MetricsConfig {
otlp_headers: BTreeMap::from([("Bad\nKey".to_owned(), "value".to_owned())]),
..config
};
let err = exporter_for(&config).unwrap_err();
assert_eq!(err, OtlpConfigError::BadHeaderKey("Bad\nKey".to_owned()));
assert!(
!format!("{err}").contains("value"),
"a refused key should not carry the value either: {err}"
);
}
#[test]
fn an_endpoint_that_is_not_a_url_is_refused() {
let config = MetricsConfig {
otlp_endpoint: Some("collector.internal:4318".to_owned()),
..MetricsConfig::default()
};
let err = exporter_for(&config).unwrap_err();
let OtlpConfigError::Url(reason) = &err else {
panic!("wrong variant: {err:?}");
};
assert!(
reason.contains("does not start with http:// or https://"),
"{reason}"
);
}
#[test]
fn an_endpoint_carrying_credentials_is_refused_without_echoing_them() {
let config = MetricsConfig {
otlp_endpoint: Some("https://user:hunter2@collector.internal:4318".to_owned()),
..MetricsConfig::default()
};
let err = exporter_for(&config).unwrap_err();
assert!(matches!(err, OtlpConfigError::Url(_)), "{err:?}");
assert!(!format!("{err} {err:?}").contains("hunter2"), "{err}");
}
#[test]
fn the_endpoint_gets_the_metrics_path_appended() {
for (endpoint, path) in [
("http://127.0.0.1:4318", "/v1/metrics"),
("http://127.0.0.1:4318/", "/v1/metrics"),
("http://127.0.0.1:4318///", "/v1/metrics"),
("https://otlp.internal/collect", "/collect/v1/metrics"),
(
"https://otlp.internal?token=abc",
"/v1/metrics?token=abc",
),
(
"http://127.0.0.1:4318/?token=abc",
"/v1/metrics?token=abc",
),
("http://127.0.0.1:4318/collect/", "/collect/v1/metrics"),
] {
let config = MetricsConfig {
otlp_endpoint: Some(endpoint.to_owned()),
..MetricsConfig::default()
};
let exporter = exporter_for(&config)
.unwrap_or_else(|err| panic!("{endpoint}: {err}"))
.expect("an endpoint is an exporter");
assert_eq!(exporter.target.path, path, "{endpoint}");
}
}
#[test]
fn an_exporters_debug_redacts_its_headers() {
let exporter = exporter_on(
"127.0.0.1:4318".parse::<SocketAddr>().unwrap(),
Duration::from_secs(60),
);
let rendered = format!("{exporter:?}");
assert_eq!(
rendered,
r#"Exporter { target: Target { https: false, host: "127.0.0.1", port: 4318, path: <redacted> }, interval: 60s, timeout: 5s, headers: <redacted>, started_unix_nano: 1699999900000000000, host_name: Some("sheepbox") }"#
);
assert!(!rendered.contains("s3cr3t"), "{rendered}");
assert!(!rendered.contains("Authorization"), "{rendered}");
}
#[tokio::test]
async fn an_export_sends_the_body_the_encoder_made() {
let collector = collector(vec![200]).await;
let exporter = exporter_on(collector.addr, Duration::from_secs(60));
let expected = encode(
&reading(),
&ExportContext {
now_unix_nano: exporter.started_unix_nano,
exporter_start_unix_nano: exporter.started_unix_nano,
host_name: exporter.host_name.as_deref(),
},
);
let body = expected.as_bytes();
fetch::post_json(
&exporter.target,
&exporter.headers,
body,
Duration::from_secs(5),
)
.await
.expect("a collector that answers 200 is a success");
let request = collector.wait_for(1).await.remove(0);
let (_head, sent_body) = request
.split_once("\r\n\r\n")
.expect("a blank line ends the head");
assert_eq!(sent_body, expected);
}
}