diff --git a/opentelemetry-exporter-geneva/geneva-uploader-ffi/src/lib.rs b/opentelemetry-exporter-geneva/geneva-uploader-ffi/src/lib.rs index 58be44d3..e3f0e51d 100644 --- a/opentelemetry-exporter-geneva/geneva-uploader-ffi/src/lib.rs +++ b/opentelemetry-exporter-geneva/geneva-uploader-ffi/src/lib.rs @@ -511,6 +511,7 @@ pub unsafe extern "C" fn geneva_client_new( role_name, role_instance, msi_resource, + user_agent_prefix: None, // FFI doesn't expose user agent prefix configuration }; // Create client @@ -1203,6 +1204,7 @@ mod tests { role_name: "testrole".to_string(), role_instance: "testinstance".to_string(), msi_resource: None, + user_agent_prefix: None, }; let client = GenevaClient::new(cfg).expect("failed to create GenevaClient with MockAuth"); @@ -1317,6 +1319,7 @@ mod tests { role_name: "testrole".to_string(), role_instance: "testinstance".to_string(), msi_resource: None, + user_agent_prefix: None, }; let client = GenevaClient::new(cfg).expect("failed to create GenevaClient with MockAuth"); diff --git a/opentelemetry-exporter-geneva/geneva-uploader/src/client.rs b/opentelemetry-exporter-geneva/geneva-uploader/src/client.rs index 6a66e537..7e870047 100644 --- a/opentelemetry-exporter-geneva/geneva-uploader/src/client.rs +++ b/opentelemetry-exporter-geneva/geneva-uploader/src/client.rs @@ -1,5 +1,6 @@ //! High-level GenevaClient for user code. Wraps config_service and ingestion_service. +use crate::common::build_geneva_headers; use crate::config_service::client::{AuthMethod, GenevaConfigClient, GenevaConfigClientConfig}; // ManagedIdentitySelector removed; no re-export needed. use crate::ingestion_service::uploader::{GenevaUploader, GenevaUploaderConfig}; @@ -31,6 +32,17 @@ pub struct GenevaClientConfig { pub tenant: String, pub role_name: String, pub role_instance: String, + /// User agent prefix for the application. Will be formatted as "`` (RustGenevaClient/0.1)". + /// If None, defaults to "RustGenevaClient/0.1". + /// + /// The prefix must contain only ASCII printable characters, be non-empty (after trimming), + /// and not exceed 200 characters in length. + /// + /// Examples: + /// - None: "RustGenevaClient/0.1" + /// - Some("MyApp/2.1.0"): "MyApp/2.1.0 (RustGenevaClient/0.1)" + /// - Some("ProductionService-1.0"): "ProductionService-1.0 (RustGenevaClient/0.1)" + pub user_agent_prefix: Option<&'static str>, pub msi_resource: Option, // Required for Managed Identity variants // Add event name/version here if constant, or per-upload if you want them per call. } @@ -54,6 +66,11 @@ impl GenevaClient { "Initializing GenevaClient" ); + // Build headers once for both services + // HeaderValue::from_str() in build_geneva_headers will automatically reject control characters + let static_headers = build_geneva_headers(cfg.user_agent_prefix) + .map_err(|e| format!("Failed to build Geneva headers: {e}"))?; + // Validate MSI resource presence for managed identity variants match cfg.auth_method { AuthMethod::SystemManagedIdentity @@ -84,6 +101,7 @@ impl GenevaClient { region: cfg.region, config_major_version: cfg.config_major_version, auth_method: cfg.auth_method, + static_headers: static_headers.clone(), msi_resource: cfg.msi_resource, }; let config_client = @@ -114,6 +132,7 @@ impl GenevaClient { source_identity, environment: cfg.environment, config_version: config_version.clone(), + static_headers: static_headers.clone(), }; let uploader = diff --git a/opentelemetry-exporter-geneva/geneva-uploader/src/common.rs b/opentelemetry-exporter-geneva/geneva-uploader/src/common.rs new file mode 100644 index 00000000..793118d0 --- /dev/null +++ b/opentelemetry-exporter-geneva/geneva-uploader/src/common.rs @@ -0,0 +1,181 @@ +//! Common utilities and validation functions shared across the Geneva uploader crate. + +use reqwest::header::{HeaderMap, HeaderValue, ACCEPT, USER_AGENT}; +use thiserror::Error; + +/// Common validation errors +#[derive(Debug, Error)] +pub(crate) enum ValidationError { + #[error("Invalid user agent: {0}")] + InvalidUserAgent(String), +} + +pub(crate) type Result = std::result::Result; + +// Builds a standardized User-Agent header for Geneva services +// Format: +// - If prefix is None or empty: "RustGenevaClient/0.1" +// - If prefix is provided: "{prefix} (RustGenevaClient/0.1)" +// +// Validation: +// - HeaderValue::from_str() automatically rejects control characters (\r, \n, \0) +// - We additionally verify the header can be represented as ASCII via to_str() +pub(crate) fn build_user_agent_header(user_agent_prefix: Option<&str>) -> Result { + let prefix = user_agent_prefix.unwrap_or(""); + + // Basic validation - length and non-empty checks + if !prefix.is_empty() { + if prefix.trim().is_empty() { + return Err(ValidationError::InvalidUserAgent( + "User agent prefix cannot be only whitespace".to_string(), + )); + } + if prefix.len() > 200 { + return Err(ValidationError::InvalidUserAgent(format!( + "User agent prefix too long: {} characters (max 200)", + prefix.len() + ))); + } + } + + // Optimize for the no-prefix case - avoid allocation + let header_value = if prefix.is_empty() { + HeaderValue::from_static("RustGenevaClient/0.1") + } else { + let user_agent = format!("{prefix} (RustGenevaClient/0.1)"); + let header_value = HeaderValue::from_str(&user_agent).map_err(|e| { + ValidationError::InvalidUserAgent(format!("Invalid User-Agent header: {e}")) + })?; + + // Verify the header can be represented as valid ASCII string + // This rejects non-ASCII characters like emojis, Chinese chars, etc. + header_value.to_str().map_err(|_| { + ValidationError::InvalidUserAgent( + "User-Agent contains non-ASCII characters".to_string(), + ) + })?; + + header_value + }; + + Ok(header_value) +} + +// Builds a complete set of HTTP headers for Geneva services +// Returns HTTP headers including User-Agent and Accept +pub(crate) fn build_geneva_headers(user_agent_prefix: Option<&str>) -> Result { + let mut headers = HeaderMap::new(); + + let user_agent = build_user_agent_header(user_agent_prefix)?; + headers.insert(USER_AGENT, user_agent); + headers.insert(ACCEPT, HeaderValue::from_static("application/json")); + + Ok(headers) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_build_user_agent_header_without_prefix() { + let header = build_user_agent_header(None).unwrap(); + assert_eq!(header.to_str().unwrap(), "RustGenevaClient/0.1"); + } + + #[test] + fn test_build_user_agent_header_with_empty_prefix() { + let header = build_user_agent_header(Some("")).unwrap(); + assert_eq!(header.to_str().unwrap(), "RustGenevaClient/0.1"); + } + + #[test] + fn test_build_user_agent_header_with_valid_prefix() { + let header = build_user_agent_header(Some("MyApp/2.1.0")).unwrap(); + assert_eq!( + header.to_str().unwrap(), + "MyApp/2.1.0 (RustGenevaClient/0.1)" + ); + } + + #[test] + fn test_build_user_agent_header_with_invalid_control_chars() { + // Control characters are automatically rejected by HeaderValue::from_str() + assert!(build_user_agent_header(Some("Invalid\nPrefix")).is_err()); + assert!(build_user_agent_header(Some("App\rName")).is_err()); + assert!(build_user_agent_header(Some("App\0Name")).is_err()); + } + + #[test] + fn test_build_user_agent_header_with_non_ascii() { + // Non-ASCII characters should be rejected because we validate with to_str() + assert!(build_user_agent_header(Some("App€Name")).is_err()); + assert!(build_user_agent_header(Some("App🌏Name")).is_err()); + assert!(build_user_agent_header(Some("App🚀Name")).is_err()); + assert!(build_user_agent_header(Some("App中文Name")).is_err()); + + // Verify error message mentions non-ASCII + let result = build_user_agent_header(Some("App中文")); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("non-ASCII")); + } + + #[test] + fn test_build_user_agent_header_length_validation() { + // Test too long prefix + let long_prefix = "a".repeat(201); + let result = build_user_agent_header(Some(&long_prefix)); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("too long")); + + // Test exactly at the limit + let max_prefix = "a".repeat(200); + assert!(build_user_agent_header(Some(&max_prefix)).is_ok()); + } + + #[test] + fn test_build_user_agent_header_whitespace_validation() { + // Only whitespace should fail + assert!(build_user_agent_header(Some(" ")).is_err()); + assert!(build_user_agent_header(Some("\t")).is_err()); + + // Whitespace within valid text is OK + assert!(build_user_agent_header(Some("My App")).is_ok()); + assert!(build_user_agent_header(Some(" MyApp ")).is_ok()); + } + + #[test] + fn test_build_geneva_headers_complete() { + let headers = build_geneva_headers(Some("TestApp/1.0")).unwrap(); + + let user_agent = headers.get(USER_AGENT).unwrap(); + assert_eq!( + user_agent.to_str().unwrap(), + "TestApp/1.0 (RustGenevaClient/0.1)" + ); + + let accept = headers.get(ACCEPT).unwrap(); + assert_eq!(accept.to_str().unwrap(), "application/json"); + } + + #[test] + fn test_build_geneva_headers_without_prefix() { + let headers = build_geneva_headers(None).unwrap(); + + let user_agent = headers.get(USER_AGENT).unwrap(); + assert_eq!(user_agent.to_str().unwrap(), "RustGenevaClient/0.1"); + + let accept = headers.get(ACCEPT).unwrap(); + assert_eq!(accept.to_str().unwrap(), "application/json"); + } + + #[test] + fn test_build_geneva_headers_with_invalid_prefix() { + let result = build_geneva_headers(Some("Invalid\rPrefix")); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("Invalid User-Agent")); + } +} diff --git a/opentelemetry-exporter-geneva/geneva-uploader/src/config_service/client.rs b/opentelemetry-exporter-geneva/geneva-uploader/src/config_service/client.rs index 474f9930..d481e201 100644 --- a/opentelemetry-exporter-geneva/geneva-uploader/src/config_service/client.rs +++ b/opentelemetry-exporter-geneva/geneva-uploader/src/config_service/client.rs @@ -2,7 +2,7 @@ use base64::{engine::general_purpose, Engine as _}; use reqwest::{ - header::{HeaderMap, HeaderValue, ACCEPT, AUTHORIZATION, USER_AGENT}, + header::{HeaderMap, AUTHORIZATION}, Client, }; use serde::Deserialize; @@ -157,7 +157,8 @@ pub(crate) struct GenevaConfigClientConfig { pub(crate) namespace: String, pub(crate) region: String, pub(crate) config_major_version: u32, - pub(crate) auth_method: AuthMethod, // agent_identity and agent_version are hardcoded for now + pub(crate) static_headers: HeaderMap, + pub(crate) auth_method: AuthMethod, pub(crate) msi_resource: Option, // Required when using any Managed Identity variant } @@ -222,6 +223,7 @@ pub(crate) struct GenevaConfigClient { precomputed_url_prefix: String, agent_identity: String, agent_version: String, + static_headers: HeaderMap, } impl fmt::Debug for GenevaConfigClient { @@ -265,10 +267,14 @@ impl GenevaConfigClient { let agent_identity = "GenevaUploader"; let agent_version = "0.1"; + // Use static headers from config + // Note: User-Agent and Accept are already set in static_headers from build_geneva_headers() + let headers = config.static_headers.clone(); + let mut client_builder = Client::builder() .http1_only() .timeout(Duration::from_secs(30)) //TODO - make this configurable - .default_headers(Self::build_static_headers(agent_identity, agent_version)); + .default_headers(headers); match &config.auth_method { // TODO: Certificate auth would be removed in favor of managed identity., @@ -381,14 +387,16 @@ impl GenevaConfigClient { ).map_err(|e| GenevaConfigClientError::InternalError(format!("Failed to write URL: {e}")))?; let http_client = client_builder.build()?; + let static_headers = config.static_headers.clone(); Ok(Self { + static_headers, config, http_client, cached_data: RwLock::new(None), precomputed_url_prefix: pre_url, agent_identity: agent_identity.to_string(), // TODO make this configurable - agent_version: "1.0".to_string(), // TODO make this configurable + agent_version: agent_version.to_string(), // TODO make this configurable }) } @@ -399,14 +407,6 @@ impl GenevaConfigClient { .map(|dt| dt.with_timezone(&Utc)) } - fn build_static_headers(agent_identity: &str, agent_version: &str) -> HeaderMap { - let mut headers = HeaderMap::new(); - let user_agent = format!("{agent_identity}-{agent_version}"); - headers.insert(USER_AGENT, HeaderValue::from_str(&user_agent).unwrap()); - headers.insert(ACCEPT, HeaderValue::from_static("application/json")); - headers - } - /// Get Azure AD token using Workload Identity (Federated Identity) /// /// Reads AZURE_CLIENT_ID, AZURE_TENANT_ID, and AZURE_FEDERATED_TOKEN_FILE from environment variables. @@ -593,7 +593,7 @@ impl GenevaConfigClient { /// - `ConfigMajorVersion`: Version string (format: "Ver{major_version}v0") /// - `TagId`: UUID for request tracking /// - **Headers**: - /// - `User-Agent`: "{agent_identity}-{agent_version}" + /// - `User-Agent`: "{prefix} (RustGenevaClient/0.1)" or "RustGenevaClient/0.1" if no prefix /// - `x-ms-client-request-id`: UUID for request tracking /// - `Accept`: "application/json" /// diff --git a/opentelemetry-exporter-geneva/geneva-uploader/src/config_service/mod.rs b/opentelemetry-exporter-geneva/geneva-uploader/src/config_service/mod.rs index dbf454ce..9b81c6d4 100644 --- a/opentelemetry-exporter-geneva/geneva-uploader/src/config_service/mod.rs +++ b/opentelemetry-exporter-geneva/geneva-uploader/src/config_service/mod.rs @@ -13,6 +13,9 @@ mod tests { #[test] fn test_config_fields() { + let static_headers = crate::common::build_geneva_headers(Some("TestApp/1.0")) + .expect("Failed to build Geneva headers"); + let config = GenevaConfigClientConfig { endpoint: "https://example.com".to_string(), environment: "env".to_string(), @@ -20,6 +23,7 @@ mod tests { namespace: "ns".to_string(), region: "region".to_string(), config_major_version: 1, + static_headers, auth_method: AuthMethod::WorkloadIdentity { resource: "https://monitor.azure.com".to_string(), }, @@ -103,6 +107,9 @@ mod tests { let (temp_p12_file, password) = generate_self_signed_p12(); + let static_headers = crate::common::build_geneva_headers(Some("MockClient/1.0")) + .expect("Failed to build Geneva headers"); + let config = GenevaConfigClientConfig { endpoint: mock_server.uri(), environment: "mockenv".into(), @@ -114,6 +121,7 @@ mod tests { path: PathBuf::from(temp_p12_file.path().to_string_lossy().to_string()), password, }, + static_headers, msi_resource: None, }; @@ -149,6 +157,9 @@ mod tests { let (temp_p12_file, password) = generate_self_signed_p12(); + let static_headers = crate::common::build_geneva_headers(Some("ErrorTestApp/1.0")) + .expect("Failed to build Geneva headers"); + let config = GenevaConfigClientConfig { endpoint: mock_server.uri(), environment: "mockenv".into(), @@ -160,6 +171,7 @@ mod tests { path: PathBuf::from(temp_p12_file.path().to_string_lossy().to_string()), password, }, + static_headers, msi_resource: None, }; @@ -198,6 +210,9 @@ mod tests { let (temp_p12_file, password) = generate_self_signed_p12(); + let static_headers = crate::common::build_geneva_headers(Some("MissingInfoTestApp/1.0")) + .expect("Failed to build Geneva headers"); + let config = GenevaConfigClientConfig { endpoint: mock_server.uri(), environment: "mockenv".into(), @@ -209,6 +224,7 @@ mod tests { path: PathBuf::from(temp_p12_file.path().to_string_lossy().to_string()), password, }, + static_headers, msi_resource: None, }; @@ -230,6 +246,9 @@ mod tests { #[cfg_attr(target_os = "macos", ignore)] // cert generated not compatible with macOS #[tokio::test] async fn test_invalid_certificate_path() { + let static_headers = crate::common::build_geneva_headers(Some("InvalidCertTestApp/1.0")) + .expect("Failed to build Geneva headers"); + let config = GenevaConfigClientConfig { endpoint: "https://example.com".to_string(), environment: "env".to_string(), @@ -241,6 +260,7 @@ mod tests { path: PathBuf::from("/nonexistent/path.p12".to_string()), password: "test".to_string(), }, + static_headers, msi_resource: None, }; @@ -294,6 +314,9 @@ mod tests { .parse::() // Convert string to u32 .expect("GENEVA_CONFIG_MAJOR_VERSION must be a valid unsigned integer"); + let static_headers = crate::common::build_geneva_headers(Some("RealServerTestApp/1.0")) + .expect("Failed to build Geneva headers"); + let config = GenevaConfigClientConfig { endpoint, environment, @@ -305,6 +328,7 @@ mod tests { path: PathBuf::from(cert_path), password: cert_password, }, + static_headers, msi_resource: None, }; diff --git a/opentelemetry-exporter-geneva/geneva-uploader/src/ingestion_service/mod.rs b/opentelemetry-exporter-geneva/geneva-uploader/src/ingestion_service/mod.rs index 31026703..aa9b703b 100644 --- a/opentelemetry-exporter-geneva/geneva-uploader/src/ingestion_service/mod.rs +++ b/opentelemetry-exporter-geneva/geneva-uploader/src/ingestion_service/mod.rs @@ -46,11 +46,17 @@ mod tests { // Define uploader config let config_version = format!("Ver{config_major_version}v0"); + + // Build the static headers once for both services + let static_headers = crate::common::build_geneva_headers(Some("TestUploader")) + .expect("Failed to build Geneva headers"); + let uploader_config = GenevaUploaderConfig { namespace: namespace.clone(), source_identity, environment: environment.clone(), config_version, + static_headers: static_headers.clone(), }; let config = GenevaConfigClientConfig { @@ -64,6 +70,7 @@ mod tests { path: cert_path, password: cert_password, }, + static_headers, msi_resource: None, }; diff --git a/opentelemetry-exporter-geneva/geneva-uploader/src/ingestion_service/uploader.rs b/opentelemetry-exporter-geneva/geneva-uploader/src/ingestion_service/uploader.rs index e530a63f..38f5c024 100644 --- a/opentelemetry-exporter-geneva/geneva-uploader/src/ingestion_service/uploader.rs +++ b/opentelemetry-exporter-geneva/geneva-uploader/src/ingestion_service/uploader.rs @@ -1,5 +1,6 @@ use crate::config_service::client::{GenevaConfigClient, GenevaConfigClientError}; use crate::payload_encoder::central_blob::BatchMetadata; +use reqwest::header::HeaderMap; use reqwest::{header, Client}; use serde::Deserialize; use serde_json::Value; @@ -107,6 +108,7 @@ pub(crate) struct GenevaUploaderConfig { #[allow(dead_code)] pub environment: String, pub config_version: String, + pub static_headers: HeaderMap, } /// Client for uploading data to Geneva Ingestion Gateway (GIG) @@ -136,12 +138,18 @@ impl GenevaUploader { header::ACCEPT, header::HeaderValue::from_static("application/json"), ); - let client = Self::build_h1_client(headers)?; + + // Merge static headers from uploader_config + for (key, value) in uploader_config.static_headers.iter() { + headers.insert(key.clone(), value.clone()); + } + + let http_client = Self::build_h1_client(headers)?; Ok(Self { config_client, config: uploader_config, - http_client: client, + http_client, }) } diff --git a/opentelemetry-exporter-geneva/geneva-uploader/src/lib.rs b/opentelemetry-exporter-geneva/geneva-uploader/src/lib.rs index 6c0cbfe8..89d50c80 100644 --- a/opentelemetry-exporter-geneva/geneva-uploader/src/lib.rs +++ b/opentelemetry-exporter-geneva/geneva-uploader/src/lib.rs @@ -1,3 +1,4 @@ +mod common; mod config_service; mod ingestion_service; mod payload_encoder; diff --git a/opentelemetry-exporter-geneva/opentelemetry-exporter-geneva/examples/basic.rs b/opentelemetry-exporter-geneva/opentelemetry-exporter-geneva/examples/basic.rs index 6763edb4..0f0bbea2 100644 --- a/opentelemetry-exporter-geneva/opentelemetry-exporter-geneva/examples/basic.rs +++ b/opentelemetry-exporter-geneva/opentelemetry-exporter-geneva/examples/basic.rs @@ -88,6 +88,7 @@ async fn main() { tenant, role_name, role_instance, + user_agent_prefix: Some("BasicExample"), msi_resource: None, }; diff --git a/opentelemetry-exporter-geneva/opentelemetry-exporter-geneva/examples/basic_msi_test.rs b/opentelemetry-exporter-geneva/opentelemetry-exporter-geneva/examples/basic_msi_test.rs index 4d296317..bfd40a31 100644 --- a/opentelemetry-exporter-geneva/opentelemetry-exporter-geneva/examples/basic_msi_test.rs +++ b/opentelemetry-exporter-geneva/opentelemetry-exporter-geneva/examples/basic_msi_test.rs @@ -104,6 +104,7 @@ async fn main() { role_name, role_instance, auth_method, + user_agent_prefix: None, msi_resource, }; diff --git a/opentelemetry-exporter-geneva/opentelemetry-exporter-geneva/examples/basic_workload_identity_test.rs b/opentelemetry-exporter-geneva/opentelemetry-exporter-geneva/examples/basic_workload_identity_test.rs index 6c4ec918..8ea15d44 100644 --- a/opentelemetry-exporter-geneva/opentelemetry-exporter-geneva/examples/basic_workload_identity_test.rs +++ b/opentelemetry-exporter-geneva/opentelemetry-exporter-geneva/examples/basic_workload_identity_test.rs @@ -83,6 +83,7 @@ async fn main() { role_name, role_instance, auth_method, + user_agent_prefix: None, msi_resource: None, // Not used for Workload Identity }; diff --git a/opentelemetry-exporter-geneva/opentelemetry-exporter-geneva/examples/trace_basic.rs b/opentelemetry-exporter-geneva/opentelemetry-exporter-geneva/examples/trace_basic.rs index 3388bf4f..9baa504a 100644 --- a/opentelemetry-exporter-geneva/opentelemetry-exporter-geneva/examples/trace_basic.rs +++ b/opentelemetry-exporter-geneva/opentelemetry-exporter-geneva/examples/trace_basic.rs @@ -56,6 +56,7 @@ async fn main() { tenant, role_name, role_instance, + user_agent_prefix: None, msi_resource: None, }; diff --git a/stress/src/geneva_exporter.rs b/stress/src/geneva_exporter.rs index f3bdb088..ea85ad05 100644 --- a/stress/src/geneva_exporter.rs +++ b/stress/src/geneva_exporter.rs @@ -124,6 +124,7 @@ async fn init_client() -> Result<(GenevaClient, Option), Box Result<(GenevaClient, Option), Box Result<(GenevaClient, Option), Box