Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
d0792f3
fix
lalitb Jul 30, 2025
880d6e5
fix
lalitb Jul 30, 2025
0a60b82
fix
lalitb Jul 30, 2025
78be687
more changes
lalitb Jul 30, 2025
a0797f7
Merge branch 'main' into custom-user-agent
lalitb Aug 1, 2025
99f10d7
fix
lalitb Aug 1, 2025
2806cf3
Merge branch 'custom-user-agent' of github.com:lalitb/opentelemetry-r…
lalitb Aug 1, 2025
606caf6
Merge branch 'main' into custom-user-agent
lalitb Aug 1, 2025
76da83c
fix
lalitb Aug 1, 2025
a381d16
Merge branch 'custom-user-agent' of github.com:lalitb/opentelemetry-r…
lalitb Aug 1, 2025
50d94cd
fix
lalitb Aug 1, 2025
7ecda1b
fix
lalitb Aug 1, 2025
7b2d972
fix
lalitb Aug 1, 2025
d912832
fix
lalitb Aug 1, 2025
504845e
add missing common.rs
lalitb Aug 1, 2025
5881c06
add user-agent support in ingestion_service
lalitb Aug 1, 2025
f57c640
fix warning
lalitb Aug 1, 2025
80e4528
fix
lalitb Aug 1, 2025
d623496
more rearrange
lalitb Aug 1, 2025
9cb1145
fix
lalitb Aug 1, 2025
3c02e09
add todo
lalitb Aug 1, 2025
683820c
fix
lalitb Aug 1, 2025
1675d6c
reformat
lalitb Aug 1, 2025
c7121d1
build errors
lalitb Aug 1, 2025
2ec20c4
fix
lalitb Aug 1, 2025
6ab85ab
Merge branch 'main' into custom-user-agent
lalitb Aug 3, 2025
6fb82cb
Merge branch 'main' into custom-user-agent
lalitb Aug 6, 2025
643e5ab
Merge branch 'main' into custom-user-agent
lalitb Nov 3, 2025
e2d84be
remove non-ascii validation
lalitb Nov 3, 2025
f5d840d
fix nit
lalitb Nov 3, 2025
ca74e81
nit doc
lalitb Nov 3, 2025
a2d68e4
more build fix
lalitb Nov 4, 2025
143548a
doc fix
lalitb Nov 4, 2025
aa6fb5e
fix
lalitb Nov 4, 2025
File filter

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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");

Expand Down Expand Up @@ -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");

Expand Down
19 changes: 19 additions & 0 deletions opentelemetry-exporter-geneva/geneva-uploader/src/client.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand Down Expand Up @@ -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 "`<prefix>` (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<String>, // Required for Managed Identity variants
// Add event name/version here if constant, or per-upload if you want them per call.
}
Expand All @@ -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
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -114,6 +132,7 @@ impl GenevaClient {
source_identity,
environment: cfg.environment,
config_version: config_version.clone(),
static_headers: static_headers.clone(),
};

let uploader =
Expand Down
181 changes: 181 additions & 0 deletions opentelemetry-exporter-geneva/geneva-uploader/src/common.rs
Original file line number Diff line number Diff line change
@@ -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<T> = std::result::Result<T, ValidationError>;

// 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<HeaderValue> {
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<HeaderMap> {
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"));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<String>, // Required when using any Managed Identity variant
}

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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.,
Expand Down Expand Up @@ -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
})
}

Expand All @@ -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.
Expand Down Expand Up @@ -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"
///
Expand Down
Loading