use std::time::Duration;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Protocol {
#[default]
Grpc,
HttpProtobuf,
}
#[derive(Debug, Clone)]
pub struct OtelConfig {
pub service_name: String,
pub service_version: Option<String>,
pub gen_ai_system: String,
pub endpoint: String,
pub protocol: Protocol,
pub timeout: Duration,
pub headers: Vec<(String, String)>,
}
const OTLP_DEFAULT_EXPORT_TIMEOUT: Duration = Duration::from_secs(5);
impl OtelConfig {
pub fn new(service_name: impl Into<String>) -> Self {
Self {
service_name: service_name.into(),
service_version: None,
gen_ai_system: "unknown".to_string(),
endpoint: "http://localhost:4317".to_string(),
protocol: Protocol::Grpc,
timeout: OTLP_DEFAULT_EXPORT_TIMEOUT,
headers: Vec::new(),
}
}
#[must_use]
pub fn endpoint(mut self, endpoint: impl Into<String>) -> Self {
self.endpoint = endpoint.into();
self
}
#[must_use]
pub fn with_header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
self.headers.push((name.into(), value.into()));
self
}
#[must_use]
pub fn bearer_token(self, token: impl AsRef<str>) -> Self {
self.with_header("authorization", format!("Bearer {}", token.as_ref()))
}
#[must_use]
pub fn protocol(mut self, protocol: Protocol) -> Self {
self.protocol = protocol;
self
}
#[must_use]
pub fn timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
#[must_use]
pub fn service_version(mut self, version: impl Into<String>) -> Self {
self.service_version = Some(version.into());
self
}
#[must_use]
pub fn gen_ai_system(mut self, system: impl Into<String>) -> Self {
self.gen_ai_system = system.into();
self
}
}
pub(crate) fn is_plaintext_remote_otlp(endpoint: &str) -> bool {
if endpoint.starts_with("https://") {
return false;
}
let after_scheme = endpoint
.strip_prefix("http://")
.or_else(|| endpoint.strip_prefix("grpc://"))
.unwrap_or(endpoint);
let authority = after_scheme.split('/').next().unwrap_or("");
let host = if let Some(end) = authority.find(']') {
&authority[..=end]
} else {
authority.split(':').next().unwrap_or("")
};
!matches!(host, "localhost" | "127.0.0.1" | "[::1]" | "::1")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn defaults_match_otel_conventions() {
let cfg = OtelConfig::new("svc");
assert_eq!(cfg.endpoint, "http://localhost:4317");
assert!(matches!(cfg.protocol, Protocol::Grpc));
assert_eq!(cfg.timeout, Duration::from_secs(5));
assert_eq!(cfg.gen_ai_system, "unknown");
assert!(cfg.service_version.is_none());
assert!(cfg.headers.is_empty());
}
#[test]
fn protocol_default_is_grpc() {
assert_eq!(Protocol::default(), Protocol::Grpc);
}
#[test]
fn bearer_token_emits_authorization_header() {
let cfg = OtelConfig::new("svc").bearer_token("abc");
assert_eq!(
cfg.headers,
vec![("authorization".to_string(), "Bearer abc".to_string())]
);
}
#[test]
fn with_header_accumulates() {
let cfg = OtelConfig::new("svc")
.with_header("x-team", "team-1")
.with_header("x-env", "prod");
assert_eq!(cfg.headers.len(), 2);
}
#[test]
fn is_plaintext_remote_otlp_flags_remote_http() {
assert!(is_plaintext_remote_otlp(
"http://collector.example.com:4317"
));
assert!(is_plaintext_remote_otlp("http://10.0.0.1:4317"));
}
#[test]
fn is_plaintext_remote_otlp_allows_loopback() {
assert!(!is_plaintext_remote_otlp("http://localhost:4317"));
assert!(!is_plaintext_remote_otlp("http://127.0.0.1:4317"));
assert!(!is_plaintext_remote_otlp("http://[::1]:4317"));
}
#[test]
fn is_plaintext_remote_otlp_allows_https() {
assert!(!is_plaintext_remote_otlp(
"https://collector.example.com:4317"
));
}
#[test]
fn is_plaintext_remote_otlp_flags_schemeless_and_grpc_remote() {
assert!(is_plaintext_remote_otlp("collector.example.com:4317"));
assert!(is_plaintext_remote_otlp(
"grpc://collector.example.com:4317"
));
assert!(is_plaintext_remote_otlp("10.0.0.1:4317"));
}
#[test]
fn is_plaintext_remote_otlp_allows_schemeless_loopback() {
assert!(!is_plaintext_remote_otlp("localhost:4317"));
assert!(!is_plaintext_remote_otlp("127.0.0.1:4317"));
}
}