#![allow(clippy::single_match)]
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fmt::Display;
use thiserror::Error;
#[cfg(feature = "opentelemetry")]
use {
opentelemetry::{KeyValue, global},
opentelemetry_otlp::WithTonicConfig,
opentelemetry_otlp::{SpanExporter, WithExportConfig},
opentelemetry_sdk::propagation::TraceContextPropagator,
opentelemetry_sdk::{Resource, trace::SdkTracerProvider},
opentelemetry_semantic_conventions::attribute::{
DEPLOYMENT_ENVIRONMENT, SERVICE_INSTANCE_ID, SERVICE_VERSION,
},
tonic::metadata::{MetadataMap, MetadataValue},
};
#[derive(Error, Debug)]
pub enum OpenTelemetryError {
#[error("configuration error: {0}")]
ConfigError(#[from] crate::config::error::ConfigError),
#[error("OpenTelemetry initialization error: {0}")]
InitError(String),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpenTelemetryConfig {
#[serde(default = "default_endpoint")]
pub endpoint: String,
#[serde(default = "default_service_name")]
pub service_name: String,
#[serde(default = "default_include_headers")]
pub include_headers: bool,
#[serde(default = "default_include_bodies")]
pub include_bodies: bool,
#[serde(default = "default_max_body_size")]
pub max_body_size: usize,
#[serde(default)]
pub span_annotations: HashMap<String, String>,
#[serde(default)]
pub collector_headers: HashMap<String, String>,
#[serde(default)]
pub resource_attributes: HashMap<String, String>,
}
fn default_endpoint() -> String {
"http://localhost:4317".to_string()
}
fn default_service_name() -> String {
"foxy-proxy".to_string()
}
fn default_include_headers() -> bool {
true
}
fn default_include_bodies() -> bool {
false
}
fn default_max_body_size() -> usize {
1024
}
impl Default for OpenTelemetryConfig {
fn default() -> Self {
Self {
endpoint: default_endpoint(),
service_name: default_service_name(),
include_headers: default_include_headers(),
include_bodies: default_include_bodies(),
max_body_size: default_max_body_size(),
span_annotations: HashMap::new(),
collector_headers: HashMap::new(),
resource_attributes: HashMap::new(),
}
}
}
impl Display for OpenTelemetryConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"OpenTelemetryConfig {{ endpoint: {}, service_name: {}, include_headers: {}, include_bodies: {}, max_body_size: {}, span_annotations: {:?}, collector_headers: {:?}, resource_attributes: {:?} }}",
self.endpoint,
self.service_name,
self.include_headers,
self.include_bodies,
self.max_body_size,
self.span_annotations,
self.collector_headers,
self.resource_attributes
)
}
}
#[cfg(feature = "opentelemetry")]
pub fn init(
config: Option<OpenTelemetryConfig>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
if config.is_some() && !config.as_ref().unwrap().endpoint.is_empty() {
let config_ref = config.as_ref().unwrap();
global::set_text_map_propagator(TraceContextPropagator::new());
let mut exporter_builder = SpanExporter::builder()
.with_tonic()
.with_endpoint(config_ref.endpoint.clone());
if !config_ref.collector_headers.is_empty() {
let mut meta = MetadataMap::with_capacity(config_ref.collector_headers.len());
for (k, v) in &config_ref.collector_headers {
if let (Ok(key), Ok(val)) = (
k.parse::<tonic::metadata::MetadataKey<_>>(),
MetadataValue::try_from(v.as_str()),
) {
meta.insert(key, val);
}
}
exporter_builder = exporter_builder.with_metadata(meta);
}
let exporter = exporter_builder
.build()
.expect("An error occurred building the OpenTelemetry exporter");
let svc_version = env!("CARGO_PKG_VERSION");
let deploy_env = std::env::var("FOXY_DEPLOY_ENV").unwrap_or_else(|_| "local".into());
let instance_id = hostname::get()
.ok()
.and_then(|h| h.into_string().ok())
.unwrap_or_else(|| "unknown-host".into());
let mut res_builder = Resource::builder()
.with_service_name(config_ref.service_name.clone())
.with_attribute(KeyValue::new(SERVICE_VERSION, svc_version))
.with_attribute(KeyValue::new(DEPLOYMENT_ENVIRONMENT, deploy_env))
.with_attribute(KeyValue::new(SERVICE_INSTANCE_ID, instance_id));
if !config_ref.resource_attributes.is_empty() {
let attrs = config_ref
.resource_attributes
.iter()
.map(|(k, v)| KeyValue::new(k.clone(), v.clone()));
res_builder = res_builder.with_attributes(attrs);
};
let resource = res_builder.build();
let provider = SdkTracerProvider::builder()
.with_batch_exporter(exporter)
.with_resource(resource)
.build();
global::set_tracer_provider(provider);
}
Ok(())
}
#[cfg(not(feature = "opentelemetry"))]
pub fn init(
_cfg: Option<OpenTelemetryConfig>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
#[test]
fn test_opentelemetry_config_default() {
let config = OpenTelemetryConfig::default();
assert_eq!(config.endpoint, "http://localhost:4317");
assert_eq!(config.service_name, "foxy-proxy");
assert!(config.include_headers);
assert!(!config.include_bodies);
assert_eq!(config.max_body_size, 1024);
assert!(config.span_annotations.is_empty());
assert!(config.collector_headers.is_empty());
assert!(config.resource_attributes.is_empty());
}
#[test]
fn test_opentelemetry_config_custom() {
let mut span_annotations = HashMap::new();
span_annotations.insert("custom.key".to_string(), "custom.value".to_string());
let mut collector_headers = HashMap::new();
collector_headers.insert("X-API-Key".to_string(), "secret-key".to_string());
let mut resource_attributes = HashMap::new();
resource_attributes.insert("service.version".to_string(), "1.0.0".to_string());
let config = OpenTelemetryConfig {
endpoint: "http://custom-collector:4317".to_string(),
service_name: "custom-service".to_string(),
include_headers: false,
include_bodies: true,
max_body_size: 2048,
span_annotations,
collector_headers,
resource_attributes,
};
assert_eq!(config.endpoint, "http://custom-collector:4317");
assert_eq!(config.service_name, "custom-service");
assert!(!config.include_headers);
assert!(config.include_bodies);
assert_eq!(config.max_body_size, 2048);
assert_eq!(
config.span_annotations.get("custom.key"),
Some(&"custom.value".to_string())
);
assert_eq!(
config.collector_headers.get("X-API-Key"),
Some(&"secret-key".to_string())
);
assert_eq!(
config.resource_attributes.get("service.version"),
Some(&"1.0.0".to_string())
);
}
#[test]
fn test_opentelemetry_config_display() {
let config = OpenTelemetryConfig::default();
let display_str = format!("{}", config);
assert!(display_str.contains("endpoint: http://localhost:4317"));
assert!(display_str.contains("service_name: foxy-proxy"));
assert!(display_str.contains("include_headers: true"));
assert!(display_str.contains("include_bodies: false"));
assert!(display_str.contains("max_body_size: 1024"));
}
#[test]
fn test_opentelemetry_config_serialization() {
let config = OpenTelemetryConfig::default();
let serialized = serde_json::to_string(&config).expect("Failed to serialize config");
assert!(serialized.contains("\"endpoint\":\"http://localhost:4317\""));
assert!(serialized.contains("\"service_name\":\"foxy-proxy\""));
let deserialized: OpenTelemetryConfig =
serde_json::from_str(&serialized).expect("Failed to deserialize config");
assert_eq!(deserialized.endpoint, config.endpoint);
assert_eq!(deserialized.service_name, config.service_name);
assert_eq!(deserialized.include_headers, config.include_headers);
assert_eq!(deserialized.include_bodies, config.include_bodies);
assert_eq!(deserialized.max_body_size, config.max_body_size);
}
#[test]
fn test_opentelemetry_config_partial_deserialization() {
let partial_json = r#"{"endpoint": "http://custom:4317"}"#;
let config: OpenTelemetryConfig =
serde_json::from_str(partial_json).expect("Failed to deserialize partial config");
assert_eq!(config.endpoint, "http://custom:4317");
assert_eq!(config.service_name, "foxy-proxy"); assert!(config.include_headers); assert!(!config.include_bodies); assert_eq!(config.max_body_size, 1024); }
#[test]
fn test_opentelemetry_error_display() {
let config_error = crate::config::error::ConfigError::ParseError("test error".to_string());
let otel_error = OpenTelemetryError::ConfigError(config_error);
let error_str = format!("{}", otel_error);
assert!(error_str.contains("configuration error"));
assert!(error_str.contains("test error"));
}
#[test]
fn test_opentelemetry_init_error() {
let init_error = OpenTelemetryError::InitError("initialization failed".to_string());
let error_str = format!("{}", init_error);
assert!(error_str.contains("OpenTelemetry initialization error"));
assert!(error_str.contains("initialization failed"));
}
#[test]
fn test_default_functions() {
assert_eq!(default_endpoint(), "http://localhost:4317");
assert_eq!(default_service_name(), "foxy-proxy");
assert!(default_include_headers());
assert!(!default_include_bodies());
assert_eq!(default_max_body_size(), 1024);
}
#[cfg(not(feature = "opentelemetry"))]
#[test]
fn test_init_noop() {
let config = OpenTelemetryConfig::default();
let result = init(Some(config));
assert!(result.is_ok());
}
#[cfg(feature = "opentelemetry")]
#[test]
fn test_init_with_empty_endpoint() {
let config = OpenTelemetryConfig {
endpoint: "".to_string(),
..Default::default()
};
let result = init(Some(config));
assert!(result.is_ok());
}
#[cfg(feature = "opentelemetry")]
#[test]
fn test_init_with_none_config() {
let result = init(None);
assert!(result.is_ok());
}
#[cfg(feature = "opentelemetry")]
#[tokio::test]
async fn test_init_with_custom_headers() {
let mut config = OpenTelemetryConfig {
endpoint: "http://localhost:4317".to_string(),
..Default::default()
};
config
.collector_headers
.insert("x-api-key".to_string(), "test-key".to_string());
config
.collector_headers
.insert("authorization".to_string(), "Bearer token".to_string());
let result = init(Some(config));
assert!(result.is_ok());
}
#[cfg(feature = "opentelemetry")]
#[tokio::test]
async fn test_init_with_custom_resource_attributes() {
let mut config = OpenTelemetryConfig {
endpoint: "http://localhost:4317".to_string(),
..Default::default()
};
config
.resource_attributes
.insert("service.version".to_string(), "1.0.0".to_string());
config
.resource_attributes
.insert("deployment.environment".to_string(), "test".to_string());
let result = init(Some(config));
assert!(result.is_ok());
}
#[cfg(feature = "opentelemetry")]
#[tokio::test]
async fn test_init_with_invalid_headers() {
let mut config = OpenTelemetryConfig {
endpoint: "http://localhost:4317".to_string(),
..Default::default()
};
config
.collector_headers
.insert("invalid\nheader".to_string(), "value".to_string());
config
.collector_headers
.insert("valid-header".to_string(), "valid-value".to_string());
let result = init(Some(config));
assert!(result.is_ok());
}
}