#![forbid(unsafe_code)]
#![deny(missing_docs)]
pub mod backoff;
pub use crate::backoff::{CircuitBreaker, RetryPolicy};
#[cfg(feature = "async")]
#[cfg_attr(docsrs, doc(cfg(feature = "async")))]
pub mod async_http;
#[cfg(feature = "async")]
#[cfg_attr(docsrs, doc(cfg(feature = "async")))]
pub use crate::async_http::{
AsyncOtlpExporter, AsyncOtlpExporterBuilder,
};
#[cfg(feature = "grpc")]
#[cfg_attr(docsrs, doc(cfg(feature = "grpc")))]
pub mod grpc;
#[cfg(feature = "grpc")]
#[cfg_attr(docsrs, doc(cfg(feature = "grpc")))]
pub use crate::grpc::{GrpcOtlpExporter, GrpcOtlpExporterBuilder};
use crate::backoff::cheap_random_0_to_1;
use rlg::log::Log;
use rlg::log_format::LogFormat;
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum OtlpError {
#[error("OTLP transport error: {0}")]
Transport(#[from] Box<ureq::Error>),
#[error("OTLP collector returned status {0}")]
BadStatus(u16),
#[error("OTLP serialise error: {0}")]
Serialise(#[from] serde_json::Error),
#[error("OTLP circuit breaker tripped (too many recent failures)")]
CircuitOpen,
#[cfg(feature = "async")]
#[cfg_attr(docsrs, doc(cfg(feature = "async")))]
#[error("OTLP async transport error: {0}")]
AsyncTransport(Box<reqwest::Error>),
#[cfg(feature = "grpc")]
#[cfg_attr(docsrs, doc(cfg(feature = "grpc")))]
#[error("OTLP gRPC endpoint error: {0}")]
GrpcEndpoint(String),
#[cfg(feature = "grpc")]
#[cfg_attr(docsrs, doc(cfg(feature = "grpc")))]
#[error("OTLP gRPC send not implemented (Phase 19c.1)")]
GrpcNotImplemented,
}
pub type OtlpResult<T> = Result<T, OtlpError>;
#[derive(Debug, Clone)]
pub struct OtlpExporter {
endpoint: String,
headers: HashMap<String, String>,
timeout: Duration,
retry: RetryPolicy,
circuit: Option<Arc<CircuitBreaker>>,
}
impl OtlpExporter {
#[must_use]
pub fn builder() -> OtlpExporterBuilder {
OtlpExporterBuilder::default()
}
pub fn export_one(&self, record: &Log) -> OtlpResult<()> {
self.export_batch(std::slice::from_ref(record))
}
pub fn export_batch(&self, records: &[Log]) -> OtlpResult<()> {
let body = serialise_batch(records)?;
self.post(&body)
}
fn post(&self, body: &str) -> OtlpResult<()> {
if let Some(cb) = &self.circuit
&& !cb.allow()
{
return Err(OtlpError::CircuitOpen);
}
let agent = ureq::Agent::config_builder()
.timeout_global(Some(self.timeout))
.build()
.new_agent();
let mut attempt: u32 = 0;
loop {
let mut req = agent
.post(&self.endpoint)
.header("content-type", "application/json");
for (k, v) in &self.headers {
req = req.header(k.as_str(), v.as_str());
}
match req.send(body) {
Ok(response) => {
let status = response.status().as_u16();
if status >= 500 || status == 429 {
if attempt < self.retry.max_retries {
self.sleep_for_attempt(attempt);
attempt += 1;
continue;
}
if let Some(cb) = &self.circuit {
cb.record_failure();
}
return Err(OtlpError::BadStatus(status));
}
if !(200..300).contains(&status) {
if let Some(cb) = &self.circuit {
cb.record_failure();
}
return Err(OtlpError::BadStatus(status));
}
if let Some(cb) = &self.circuit {
cb.record_success();
}
return Ok(());
}
Err(e) => {
if attempt < self.retry.max_retries {
self.sleep_for_attempt(attempt);
attempt += 1;
continue;
}
if let Some(cb) = &self.circuit {
cb.record_failure();
}
return Err(OtlpError::Transport(Box::new(e)));
}
}
}
}
fn sleep_for_attempt(&self, attempt: u32) {
std::thread::sleep(
self.retry.delay(attempt, cheap_random_0_to_1()),
);
}
#[must_use]
pub fn endpoint(&self) -> &str {
&self.endpoint
}
}
#[derive(Debug, Default, Clone)]
pub struct OtlpExporterBuilder {
endpoint: Option<String>,
headers: HashMap<String, String>,
timeout: Option<Duration>,
max_retries: Option<u32>,
backoff_base: Option<Duration>,
circuit: Option<Arc<CircuitBreaker>>,
}
impl OtlpExporterBuilder {
#[must_use]
pub fn endpoint(mut self, url: impl Into<String>) -> Self {
self.endpoint = Some(url.into());
self
}
#[must_use]
pub fn header(
mut self,
name: impl Into<String>,
value: impl Into<String>,
) -> Self {
self.headers.insert(name.into(), value.into());
self
}
#[must_use]
pub fn timeout_secs(mut self, secs: u64) -> Self {
self.timeout = Some(Duration::from_secs(secs));
self
}
#[must_use]
pub const fn max_retries(mut self, n: u32) -> Self {
self.max_retries = Some(n);
self
}
#[must_use]
pub const fn backoff_base(mut self, base: Duration) -> Self {
self.backoff_base = Some(base);
self
}
#[must_use]
pub fn circuit(mut self, cb: Arc<CircuitBreaker>) -> Self {
self.circuit = Some(cb);
self
}
#[must_use]
pub fn build(self) -> OtlpExporter {
let retry = RetryPolicy {
max_retries: self.max_retries.unwrap_or(3),
base: self
.backoff_base
.unwrap_or_else(|| Duration::from_millis(200)),
max_delay: Duration::from_secs(30),
jitter: 1.0,
};
OtlpExporter {
endpoint: self
.endpoint
.expect("OtlpExporterBuilder::endpoint is required"),
headers: self.headers,
timeout: self
.timeout
.unwrap_or_else(|| Duration::from_secs(10)),
retry,
circuit: self.circuit,
}
}
}
pub fn serialise_batch(records: &[Log]) -> OtlpResult<String> {
let mut log_records = Vec::with_capacity(records.len());
for record in records {
let mut copy = record.clone();
copy.format = LogFormat::OTLP;
let rendered = format!("{copy}");
let parsed: serde_json::Value =
serde_json::from_str(&rendered)?;
log_records.push(parsed);
}
let envelope = serde_json::json!({
"resourceLogs": [{
"resource": {
"attributes": [{
"key": "service.name",
"value": { "stringValue": "rlg" }
}]
},
"scopeLogs": [{
"scope": {
"name": "rlg-otlp",
"version": env!("CARGO_PKG_VERSION")
},
"logRecords": log_records
}]
}]
});
Ok(envelope.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use rlg::log_level::LogLevel;
fn sample(level: LogLevel) -> Log {
Log::build(level, "msg")
.component("svc")
.session_id(7)
.time("2026-05-30T00:00:00.000000000Z")
.with("trace_id", "abc")
.with("span_id", "def")
.format(LogFormat::OTLP)
}
#[test]
fn builder_defaults_to_sensible_values() {
let e = OtlpExporter::builder()
.endpoint("http://x/v1/logs")
.build();
assert_eq!(e.timeout, Duration::from_secs(10));
assert_eq!(e.retry.max_retries, 3);
assert_eq!(e.retry.base, Duration::from_millis(200));
}
#[test]
fn builder_sets_headers_and_timeout() {
let e = OtlpExporter::builder()
.endpoint("http://x/v1/logs")
.header("x-honeycomb-team", "key123")
.timeout_secs(30)
.build();
assert_eq!(
e.headers.get("x-honeycomb-team").unwrap(),
"key123"
);
assert_eq!(e.timeout, Duration::from_secs(30));
assert_eq!(e.endpoint(), "http://x/v1/logs");
}
#[test]
fn builder_sets_retry_policy() {
let e = OtlpExporter::builder()
.endpoint("http://x/v1/logs")
.max_retries(5)
.backoff_base(Duration::from_millis(50))
.build();
assert_eq!(e.retry.max_retries, 5);
assert_eq!(e.retry.base, Duration::from_millis(50));
}
#[test]
fn retries_can_be_disabled() {
let e = OtlpExporter::builder()
.endpoint("http://x/v1/logs")
.max_retries(0)
.build();
assert_eq!(e.retry.max_retries, 0);
}
#[test]
fn serialise_batch_wraps_in_resource_logs_envelope() {
let body = serialise_batch(&[sample(LogLevel::INFO)]).unwrap();
let v: serde_json::Value = serde_json::from_str(&body).unwrap();
let log_records =
&v["resourceLogs"][0]["scopeLogs"][0]["logRecords"];
assert!(log_records.is_array());
assert_eq!(log_records.as_array().unwrap().len(), 1);
assert_eq!(
v["resourceLogs"][0]["resource"]["attributes"][0]["key"],
"service.name"
);
assert_eq!(
v["resourceLogs"][0]["scopeLogs"][0]["scope"]["name"],
"rlg-otlp"
);
}
#[test]
fn serialise_batch_handles_empty_input() {
let body = serialise_batch(&[]).unwrap();
let v: serde_json::Value = serde_json::from_str(&body).unwrap();
let log_records =
&v["resourceLogs"][0]["scopeLogs"][0]["logRecords"];
assert_eq!(log_records.as_array().unwrap().len(), 0);
}
#[test]
fn serialise_batch_includes_every_record() {
let body = serialise_batch(&[
sample(LogLevel::INFO),
sample(LogLevel::ERROR),
sample(LogLevel::WARN),
])
.unwrap();
let v: serde_json::Value = serde_json::from_str(&body).unwrap();
assert_eq!(
v["resourceLogs"][0]["scopeLogs"][0]["logRecords"]
.as_array()
.unwrap()
.len(),
3
);
}
#[test]
fn export_one_against_invalid_endpoint_errors() {
let e = OtlpExporter::builder()
.endpoint("http://127.0.0.1:1/v1/logs")
.timeout_secs(1)
.max_retries(0)
.build();
let res = e.export_one(&sample(LogLevel::INFO));
assert!(matches!(
res,
Err(OtlpError::Transport(_)) | Err(OtlpError::BadStatus(_))
));
}
#[test]
fn retry_loop_exhausts_attempts_on_transport_error() {
let e = OtlpExporter::builder()
.endpoint("http://127.0.0.1:1/v1/logs")
.timeout_secs(1)
.max_retries(3)
.backoff_base(Duration::ZERO)
.build();
let res = e.export_one(&sample(LogLevel::INFO));
assert!(matches!(
res,
Err(OtlpError::Transport(_)) | Err(OtlpError::BadStatus(_))
));
}
#[test]
fn sleep_for_attempt_with_zero_base_is_instant() {
let e = OtlpExporter::builder()
.endpoint("http://x")
.backoff_base(Duration::ZERO)
.build();
let start = std::time::Instant::now();
e.sleep_for_attempt(0);
e.sleep_for_attempt(5);
e.sleep_for_attempt(20);
assert!(start.elapsed() < Duration::from_millis(50));
}
#[test]
fn sleep_for_attempt_caps_at_thirty_seconds() {
let e = OtlpExporter::builder()
.endpoint("http://x")
.backoff_base(Duration::from_micros(1))
.build();
let _ = e.retry.max_retries; }
#[test]
#[should_panic(expected = "endpoint is required")]
fn builder_without_endpoint_panics() {
let _ = OtlpExporter::builder().build();
}
#[test]
fn otlp_error_display_messages() {
let err = OtlpError::BadStatus(503);
assert!(err.to_string().contains("503"));
let err = OtlpError::Serialise(
serde_json::from_str::<serde_json::Value>("not json")
.unwrap_err(),
);
assert!(err.to_string().contains("serialise"));
}
}