use std::sync::Arc;
use std::time::{Duration, SystemTime};
use bytes::Bytes;
use http_body_util::{BodyExt, Full};
use hyper::{Request, StatusCode, Uri};
use hyper_util::client::legacy::Client;
use hyper_util::client::legacy::connect::HttpConnector;
use hyper_util::rt::TokioExecutor;
use plecto_control::otlp::{OtlpBuffer, decode_export_partial_success, encode_traces_request};
use tokio::sync::watch;
use crate::listener::drained;
use crate::upstream_connector;
const SERVICE_NAME: &str = "plecto";
const MAX_EXPORT_BATCH: usize = 512;
const EXPORT_REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
const TICK: Duration = Duration::from_millis(250);
const MAX_ATTEMPTS: u32 = 3;
const BACKOFF_BASE: Duration = Duration::from_millis(500);
const BACKOFF_CAP: Duration = Duration::from_secs(5);
pub(crate) const SHUTDOWN_FLUSH_DEADLINE: Duration = Duration::from_secs(3);
type OtlpClient = Client<HttpConnector, Full<Bytes>>;
fn traces_uri(endpoint: &str) -> Option<Uri> {
if !endpoint.starts_with("http://") {
tracing::error!(
endpoint,
"otlp_endpoint must be an http:// base URL (TLS export is not yet supported); \
trace export disabled"
);
return None;
}
let base = endpoint.trim_end_matches('/');
match format!("{base}/v1/traces").parse::<Uri>() {
Ok(uri) => Some(uri),
Err(e) => {
tracing::error!(endpoint, error = %e, "invalid otlp_endpoint; trace export disabled");
None
}
}
}
pub(crate) async fn serve_otlp_export(
buffer: Arc<OtlpBuffer>,
endpoint: String,
mut drain: watch::Receiver<bool>,
) {
let Some(uri) = traces_uri(&endpoint) else {
return;
};
let client: OtlpClient = Client::builder(TokioExecutor::new()).build(upstream_connector());
tracing::info!(endpoint, "OTLP trace export running");
loop {
tokio::select! {
_ = tokio::time::sleep(TICK) => {
export_until_empty(&client, &uri, &buffer, MAX_ATTEMPTS).await;
}
_ = drained(&mut drain) => {
let flush = export_until_empty(&client, &uri, &buffer, 1);
if tokio::time::timeout(SHUTDOWN_FLUSH_DEADLINE, flush).await.is_err() {
let left = buffer.len() as u64;
buffer.record_dropped(left);
tracing::warn!(
dropped = left,
"OTLP shutdown flush deadline expired; dropping remaining spans"
);
}
return;
}
}
}
}
async fn export_until_empty(
client: &OtlpClient,
uri: &Uri,
buffer: &OtlpBuffer,
max_attempts: u32,
) {
loop {
let batch = buffer.drain(MAX_EXPORT_BATCH);
if batch.is_empty() {
return;
}
let len = batch.len() as u64;
let bytes = Bytes::from(encode_traces_request(SERVICE_NAME, &batch));
if !export_batch(client, uri, bytes, max_attempts).await {
buffer.record_dropped(len);
}
}
}
async fn export_batch(client: &OtlpClient, uri: &Uri, bytes: Bytes, max_attempts: u32) -> bool {
for attempt in 0..max_attempts {
let request = Request::post(uri.clone())
.header(hyper::header::CONTENT_TYPE, "application/x-protobuf")
.header(
hyper::header::USER_AGENT,
concat!("plecto-otlp-exporter/", env!("CARGO_PKG_VERSION")),
)
.body(Full::new(bytes.clone()));
let Ok(request) = request else {
tracing::error!("failed to build OTLP export request; dropping batch");
return false;
};
let response = tokio::time::timeout(EXPORT_REQUEST_TIMEOUT, client.request(request)).await;
let retry_after = match response {
Ok(Ok(resp)) => {
let status = resp.status();
if status == StatusCode::OK {
let body = resp.into_body().collect().await.map(|b| b.to_bytes());
if let Ok(body) = body
&& let Some(partial) = decode_export_partial_success(&body)
{
tracing::warn!(
rejected = partial.rejected_spans,
message = %partial.error_message,
"OTLP collector partially rejected a batch (not retried, per spec)"
);
}
return true;
}
if !is_retryable(status) {
tracing::warn!(%status, "OTLP export rejected; dropping batch (not retryable)");
return false;
}
let after = retry_after_seconds(&resp);
tracing::debug!(%status, attempt, "OTLP export throttled/unavailable");
after
}
Ok(Err(e)) => {
tracing::debug!(error = %e, attempt, "OTLP export transport failure");
None
}
Err(_) => {
tracing::debug!(attempt, "OTLP export request timed out");
None
}
};
if attempt + 1 < max_attempts {
tokio::time::sleep(retry_after.unwrap_or_else(|| backoff(attempt))).await;
}
}
tracing::warn!(
attempts = max_attempts,
"OTLP export gave up; dropping batch"
);
false
}
fn is_retryable(status: StatusCode) -> bool {
matches!(status.as_u16(), 429 | 502 | 503 | 504)
}
fn retry_after_seconds<B>(resp: &hyper::Response<B>) -> Option<Duration> {
let secs: u64 = resp
.headers()
.get(hyper::header::RETRY_AFTER)?
.to_str()
.ok()?
.trim()
.parse()
.ok()?;
Some(Duration::from_secs(secs).min(BACKOFF_CAP))
}
fn backoff(attempt: u32) -> Duration {
let base = BACKOFF_BASE.saturating_mul(1 << attempt.min(4));
let jitter_ms = u64::from(
SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map(|d| d.subsec_nanos())
.unwrap_or(0)
% 250,
);
(base + Duration::from_millis(jitter_ms)).min(BACKOFF_CAP)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn traces_uri_appends_the_signal_path_to_the_base() {
let uri = traces_uri("http://127.0.0.1:4318").expect("plain base parses");
assert_eq!(uri.to_string(), "http://127.0.0.1:4318/v1/traces");
let trailing = traces_uri("http://collector:4318/").expect("trailing slash is normalised");
assert_eq!(trailing.to_string(), "http://collector:4318/v1/traces");
}
#[test]
fn traces_uri_rejects_non_http_endpoints() {
assert!(
traces_uri("https://collector:4318").is_none(),
"TLS export unsupported"
);
assert!(traces_uri("collector:4318").is_none(), "scheme is required");
assert!(traces_uri("http://bad url").is_none());
}
#[test]
fn retryable_statuses_match_the_spec_exactly() {
for code in [429u16, 502, 503, 504] {
assert!(is_retryable(StatusCode::from_u16(code).unwrap()), "{code}");
}
for code in [400u16, 401, 403, 404, 500, 501] {
assert!(!is_retryable(StatusCode::from_u16(code).unwrap()), "{code}");
}
}
#[test]
fn backoff_grows_and_stays_capped() {
assert!(backoff(0) >= BACKOFF_BASE);
assert!(backoff(10) <= BACKOFF_CAP);
}
}