use std::time::Duration;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum OtelProtocol {
#[default]
Grpc,
Http,
}
impl OtelProtocol {
pub fn parse(s: &str) -> Option<Self> {
match s.trim().to_ascii_lowercase().as_str() {
"grpc" => Some(Self::Grpc),
"http" | "http/protobuf" | "http-protobuf" | "httpbinary" => Some(Self::Http),
_ => None,
}
}
pub fn as_str(self) -> &'static str {
match self {
Self::Grpc => "grpc",
Self::Http => "http",
}
}
}
pub const DEFAULT_ENDPOINT: &str = "http://localhost:4317";
pub const DEFAULT_SERVICE_NAME: &str = "cqlite";
pub const DEFAULT_TIMEOUT: Duration = Duration::from_millis(10_000);
#[derive(Debug, Clone)]
pub struct ObservabilityConfig {
pub enabled: bool,
pub endpoint: String,
pub protocol: OtelProtocol,
pub service_name: String,
pub service_version: String,
pub sampling_ratio: f64,
pub timeout: Duration,
}
impl Default for ObservabilityConfig {
fn default() -> Self {
Self {
enabled: false,
endpoint: DEFAULT_ENDPOINT.to_string(),
protocol: OtelProtocol::Grpc,
service_name: DEFAULT_SERVICE_NAME.to_string(),
service_version: env!("CARGO_PKG_VERSION").to_string(),
sampling_ratio: 1.0,
timeout: DEFAULT_TIMEOUT,
}
}
}
impl ObservabilityConfig {
pub fn builder() -> ObservabilityConfigBuilder {
ObservabilityConfigBuilder {
config: Self::default(),
}
}
pub fn from_env() -> Self {
let mut cfg = Self::default();
if let Some(v) = env_str("CQLITE_OTEL_ENABLED") {
if let Some(b) = parse_bool(&v) {
cfg.enabled = b;
}
}
if let Some(v) = env_str("CQLITE_OTEL_ENDPOINT") {
cfg.endpoint = v;
}
if let Some(v) = env_str("CQLITE_OTEL_PROTOCOL") {
if let Some(p) = OtelProtocol::parse(&v) {
cfg.protocol = p;
}
}
if let Some(v) = env_str("CQLITE_OTEL_SERVICE_NAME") {
cfg.service_name = v;
}
if let Some(v) = env_str("CQLITE_OTEL_SERVICE_VERSION") {
cfg.service_version = v;
}
if let Some(v) = env_str("CQLITE_OTEL_SAMPLING_RATIO") {
if let Ok(r) = v.trim().parse::<f64>() {
if r.is_finite() {
cfg.sampling_ratio = r.clamp(0.0, 1.0);
}
}
}
if let Some(v) = env_str("CQLITE_OTEL_TIMEOUT_MS") {
if let Ok(ms) = v.trim().parse::<u64>() {
cfg.timeout = Duration::from_millis(ms);
}
}
cfg.sampling_ratio = sanitize_ratio(cfg.sampling_ratio);
cfg
}
}
fn sanitize_ratio(r: f64) -> f64 {
if r.is_finite() {
r.clamp(0.0, 1.0)
} else {
1.0
}
}
#[derive(Debug, Clone)]
pub struct ObservabilityConfigBuilder {
config: ObservabilityConfig,
}
impl ObservabilityConfigBuilder {
pub fn enabled(mut self, enabled: bool) -> Self {
self.config.enabled = enabled;
self
}
pub fn endpoint(mut self, endpoint: impl Into<String>) -> Self {
self.config.endpoint = endpoint.into();
self
}
pub fn protocol(mut self, protocol: OtelProtocol) -> Self {
self.config.protocol = protocol;
self
}
pub fn service_name(mut self, name: impl Into<String>) -> Self {
self.config.service_name = name.into();
self
}
pub fn service_version(mut self, version: impl Into<String>) -> Self {
self.config.service_version = version.into();
self
}
pub fn sampling_ratio(mut self, ratio: f64) -> Self {
self.config.sampling_ratio = sanitize_ratio(ratio);
self
}
pub fn timeout(mut self, timeout: Duration) -> Self {
self.config.timeout = timeout;
self
}
pub fn build(self) -> ObservabilityConfig {
let mut cfg = self.config;
cfg.sampling_ratio = sanitize_ratio(cfg.sampling_ratio);
cfg
}
}
fn env_str(key: &str) -> Option<String> {
match std::env::var(key) {
Ok(v) if !v.trim().is_empty() => Some(v),
_ => None,
}
}
fn parse_bool(s: &str) -> Option<bool> {
match s.trim().to_ascii_lowercase().as_str() {
"1" | "true" | "yes" | "on" => Some(true),
"0" | "false" | "no" | "off" => Some(false),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn defaults_are_disabled_and_sane() {
let cfg = ObservabilityConfig::default();
assert!(!cfg.enabled);
assert_eq!(cfg.endpoint, DEFAULT_ENDPOINT);
assert_eq!(cfg.protocol, OtelProtocol::Grpc);
assert_eq!(cfg.service_name, DEFAULT_SERVICE_NAME);
assert_eq!(cfg.sampling_ratio, 1.0);
assert_eq!(cfg.timeout, DEFAULT_TIMEOUT);
assert_eq!(cfg.service_version, env!("CARGO_PKG_VERSION"));
}
#[test]
fn builder_overrides_and_clamps() {
let cfg = ObservabilityConfig::builder()
.enabled(true)
.endpoint("http://collector:4317")
.protocol(OtelProtocol::Http)
.service_name("svc")
.service_version("9.9.9")
.sampling_ratio(2.0) .timeout(Duration::from_millis(500))
.build();
assert!(cfg.enabled);
assert_eq!(cfg.endpoint, "http://collector:4317");
assert_eq!(cfg.protocol, OtelProtocol::Http);
assert_eq!(cfg.service_name, "svc");
assert_eq!(cfg.service_version, "9.9.9");
assert_eq!(cfg.sampling_ratio, 1.0);
assert_eq!(cfg.timeout, Duration::from_millis(500));
}
#[test]
fn negative_ratio_clamps_to_zero() {
let cfg = ObservabilityConfig::builder().sampling_ratio(-1.0).build();
assert_eq!(cfg.sampling_ratio, 0.0);
}
#[test]
fn non_finite_ratio_falls_back_to_full_sampling() {
for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
let cfg = ObservabilityConfig::builder().sampling_ratio(bad).build();
assert!(cfg.sampling_ratio.is_finite());
assert_eq!(cfg.sampling_ratio, 1.0);
}
}
#[test]
fn protocol_parse() {
assert_eq!(OtelProtocol::parse("grpc"), Some(OtelProtocol::Grpc));
assert_eq!(OtelProtocol::parse("GRPC"), Some(OtelProtocol::Grpc));
assert_eq!(OtelProtocol::parse("http"), Some(OtelProtocol::Http));
assert_eq!(
OtelProtocol::parse("http/protobuf"),
Some(OtelProtocol::Http)
);
assert_eq!(OtelProtocol::parse("nope"), None);
assert_eq!(OtelProtocol::Grpc.as_str(), "grpc");
assert_eq!(OtelProtocol::Http.as_str(), "http");
}
#[test]
fn bool_parsing() {
for t in ["1", "true", "TRUE", "yes", "on"] {
assert_eq!(parse_bool(t), Some(true), "{t}");
}
for f in ["0", "false", "no", "off"] {
assert_eq!(parse_bool(f), Some(false), "{f}");
}
assert_eq!(parse_bool("maybe"), None);
}
#[test]
fn from_env_parses_and_falls_back() {
let keys = [
"CQLITE_OTEL_ENABLED",
"CQLITE_OTEL_ENDPOINT",
"CQLITE_OTEL_PROTOCOL",
"CQLITE_OTEL_SERVICE_NAME",
"CQLITE_OTEL_SERVICE_VERSION",
"CQLITE_OTEL_SAMPLING_RATIO",
"CQLITE_OTEL_TIMEOUT_MS",
];
let saved: Vec<_> = keys.iter().map(|k| (*k, std::env::var(k).ok())).collect();
for k in keys {
std::env::remove_var(k);
}
let cfg = ObservabilityConfig::from_env();
assert!(!cfg.enabled);
assert_eq!(cfg.endpoint, DEFAULT_ENDPOINT);
std::env::set_var("CQLITE_OTEL_ENABLED", "true");
std::env::set_var("CQLITE_OTEL_ENDPOINT", "http://c:4318");
std::env::set_var("CQLITE_OTEL_PROTOCOL", "http");
std::env::set_var("CQLITE_OTEL_SERVICE_NAME", "mysvc");
std::env::set_var("CQLITE_OTEL_SERVICE_VERSION", "1.2.3");
std::env::set_var("CQLITE_OTEL_SAMPLING_RATIO", "0.25");
std::env::set_var("CQLITE_OTEL_TIMEOUT_MS", "2500");
let cfg = ObservabilityConfig::from_env();
assert!(cfg.enabled);
assert_eq!(cfg.endpoint, "http://c:4318");
assert_eq!(cfg.protocol, OtelProtocol::Http);
assert_eq!(cfg.service_name, "mysvc");
assert_eq!(cfg.service_version, "1.2.3");
assert_eq!(cfg.sampling_ratio, 0.25);
assert_eq!(cfg.timeout, Duration::from_millis(2500));
std::env::set_var("CQLITE_OTEL_SAMPLING_RATIO", "not-a-number");
std::env::set_var("CQLITE_OTEL_TIMEOUT_MS", "xyz");
std::env::set_var("CQLITE_OTEL_PROTOCOL", "carrier-pigeon");
let cfg = ObservabilityConfig::from_env();
assert_eq!(cfg.sampling_ratio, 1.0);
assert_eq!(cfg.timeout, DEFAULT_TIMEOUT);
assert_eq!(cfg.protocol, OtelProtocol::Grpc);
for (k, v) in saved {
match v {
Some(val) => std::env::set_var(k, val),
None => std::env::remove_var(k),
}
}
}
}