This is an automated email from the ASF dual-hosted git repository.

JingsongLi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/paimon-rust.git


The following commit(s) were added to refs/heads/main by this push:
     new 73ab9c33 [core] Unify the object storage User-Agent format (#991)
73ab9c33 is described below

commit 73ab9c33902a2c3a70476ed3d28b2bd010d0defb
Author: Dapeng Sun(孙大鹏) <[email protected]>
AuthorDate: Fri Oct 2 10:07:20 2026 +0800

    [core] Unify the object storage User-Agent format (#991)
---
 Cargo.lock                            |   1 +
 crates/paimon/Cargo.toml              |   2 +
 crates/paimon/src/io/mod.rs           |  15 +-
 crates/paimon/src/io/storage.rs       |  80 ++++++++--
 crates/paimon/src/io/storage_azdls.rs |  22 ++-
 crates/paimon/src/io/storage_cos.rs   |  11 +-
 crates/paimon/src/io/storage_gcs.rs   |  11 +-
 crates/paimon/src/io/storage_obs.rs   |  11 +-
 crates/paimon/src/io/storage_oss.rs   |  64 +++++++-
 crates/paimon/src/io/storage_s3.rs    |  15 +-
 crates/paimon/src/io/user_agent.rs    | 291 ++++++++++++++++++++++++++++++++++
 11 files changed, 481 insertions(+), 42 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock
index 1ed0be40..5a29dcdc 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -4674,6 +4674,7 @@ dependencies = [
  "futures",
  "hex",
  "hmac 0.12.1",
+ "http",
  "indexmap 2.14.0",
  "jiff",
  "libloading 0.9.0",
diff --git a/crates/paimon/Cargo.toml b/crates/paimon/Cargo.toml
index 1d6696c4..865fd0d5 100644
--- a/crates/paimon/Cargo.toml
+++ b/crates/paimon/Cargo.toml
@@ -131,6 +131,8 @@ parquet = { workspace = true, features = ["async", "zstd", 
"lz4", "snap"] }
 orc-rust = "0.8.0"
 async-stream = "0.3.6"
 reqwest = { version = "0.12", features = ["json", "gzip", "deflate"] }
+# Already in the tree via `reqwest`; direct dep for the object storage 
User-Agent transport.
+http = "1"
 # DLF authentication dependencies
 base64 = "0.22"
 hex = "0.4"
diff --git a/crates/paimon/src/io/mod.rs b/crates/paimon/src/io/mod.rs
index e53ed4fa..2718ab6b 100644
--- a/crates/paimon/src/io/mod.rs
+++ b/crates/paimon/src/io/mod.rs
@@ -31,14 +31,23 @@ pub use storage::*;
     feature = "storage-oss",
     feature = "storage-s3"
 ))]
-fn with_http_transport(op: opendal::Operator) -> opendal::Operator {
+fn with_http_transport(op: opendal::Operator, user_agent: &str) -> 
opendal::Operator {
     use opendal::{HttpTransporter, OperationContext};
-    use opendal_http_transport_reqwest::ReqwestTransport;
 
-    let transport = HttpTransporter::new(ReqwestTransport::default());
+    let transport = 
HttpTransporter::new(user_agent::UserAgentTransport::new(user_agent));
     op.with_context(OperationContext::new().with_http_transport(transport))
 }
 
+#[cfg(any(
+    feature = "storage-azdls",
+    feature = "storage-cos",
+    feature = "storage-gcs",
+    feature = "storage-obs",
+    feature = "storage-oss",
+    feature = "storage-s3"
+))]
+mod user_agent;
+
 #[cfg(any(
     feature = "storage-s3",
     feature = "storage-cos",
diff --git a/crates/paimon/src/io/storage.rs b/crates/paimon/src/io/storage.rs
index 984f72e9..afa9b25a 100644
--- a/crates/paimon/src/io/storage.rs
+++ b/crates/paimon/src/io/storage.rs
@@ -90,26 +90,31 @@ pub enum Storage {
     #[cfg(feature = "storage-s3")]
     S3 {
         config: Box<S3Config>,
+        user_agent: String,
         operators: Mutex<HashMap<String, Operator>>,
     },
     #[cfg(feature = "storage-cos")]
     Cos {
         config: Box<CosConfig>,
+        user_agent: String,
         operators: Mutex<HashMap<String, Operator>>,
     },
     #[cfg(feature = "storage-azdls")]
     Azdls {
         config: Box<AzdlsStorageConfig>,
+        user_agent: String,
         operators: Mutex<HashMap<String, Operator>>,
     },
     #[cfg(feature = "storage-obs")]
     Obs {
         config: Box<ObsConfig>,
+        user_agent: String,
         operators: Mutex<HashMap<String, Operator>>,
     },
     #[cfg(feature = "storage-gcs")]
     Gcs {
         config: Box<GcsConfig>,
+        user_agent: String,
         operators: Mutex<HashMap<String, Operator>>,
     },
     #[cfg(feature = "storage-hdfs")]
@@ -162,41 +167,51 @@ impl Storage {
             }
             #[cfg(feature = "storage-s3")]
             "s3" | "s3a" => {
+                let user_agent = super::user_agent::storage_user_agent(&props);
                 let config = super::s3_config_parse(props)?;
                 Ok(Self::S3 {
                     config: Box::new(config),
+                    user_agent,
                     operators: Mutex::new(HashMap::new()),
                 })
             }
             #[cfg(feature = "storage-cos")]
             "cos" | "cosn" => {
+                let user_agent = super::user_agent::storage_user_agent(&props);
                 let config = super::cos_config_parse(props)?;
                 Ok(Self::Cos {
                     config: Box::new(config),
+                    user_agent,
                     operators: Mutex::new(HashMap::new()),
                 })
             }
             #[cfg(feature = "storage-azdls")]
             "abfs" | "abfss" | "az" | "azdfs" | "azdls" | "azure" => {
+                let user_agent = super::user_agent::storage_user_agent(&props);
                 let config = super::azdls_config_parse(props)?;
                 Ok(Self::Azdls {
                     config: Box::new(config),
+                    user_agent,
                     operators: Mutex::new(HashMap::new()),
                 })
             }
             #[cfg(feature = "storage-obs")]
             "obs" => {
+                let user_agent = super::user_agent::storage_user_agent(&props);
                 let config = super::obs_config_parse(props)?;
                 Ok(Self::Obs {
                     config: Box::new(config),
+                    user_agent,
                     operators: Mutex::new(HashMap::new()),
                 })
             }
             #[cfg(feature = "storage-gcs")]
             "gcs" | "gs" => {
+                let user_agent = super::user_agent::storage_user_agent(&props);
                 let config = super::gcs_config_parse(props)?;
                 Ok(Self::Gcs {
                     config: Box::new(config),
+                    user_agent,
                     operators: Mutex::new(HashMap::new()),
                 })
             }
@@ -255,45 +270,65 @@ impl Storage {
                 Ok((op, Cow::Borrowed(relative_path)))
             }
             #[cfg(feature = "storage-s3")]
-            Storage::S3 { config, operators } => {
+            Storage::S3 {
+                config,
+                user_agent,
+                operators,
+            } => {
                 let (bucket, relative_path) =
                     Self::bucket_and_relative_path(path, "S3", &["s3", 
"s3a"])?;
-                let op = Self::cached_s3_operator(config, operators, path, 
&bucket)?;
+                let op = Self::cached_s3_operator(config, user_agent, 
operators, path, &bucket)?;
                 Ok((op, Cow::Borrowed(relative_path)))
             }
             #[cfg(feature = "storage-cos")]
-            Storage::Cos { config, operators } => {
+            Storage::Cos {
+                config,
+                user_agent,
+                operators,
+            } => {
                 let (bucket, relative_path) =
                     Self::bucket_and_relative_path(path, "COS", &["cos", 
"cosn"])?;
                 let op = Self::cached_operator(operators, "COS", &bucket, || {
-                    super::cos_config_build(config, path)
+                    super::cos_config_build(config, path, user_agent)
                 })?;
                 Ok((op, Cow::Borrowed(relative_path)))
             }
             #[cfg(feature = "storage-azdls")]
-            Storage::Azdls { config, operators } => {
+            Storage::Azdls {
+                config,
+                user_agent,
+                operators,
+            } => {
                 let relative_path = super::azdls_relative_path(path)?;
                 let cache_key = super::azdls_operator_cache_key(config, path)?;
                 let op = Self::cached_operator(operators, "Azure", &cache_key, 
|| {
-                    super::azdls_config_build(config, path)
+                    super::azdls_config_build(config, path, user_agent)
                 })?;
                 Ok((op, Cow::Borrowed(relative_path)))
             }
             #[cfg(feature = "storage-obs")]
-            Storage::Obs { config, operators } => {
+            Storage::Obs {
+                config,
+                user_agent,
+                operators,
+            } => {
                 let (bucket, relative_path) =
                     Self::bucket_and_relative_path(path, "OBS", &["obs"])?;
                 let op = Self::cached_operator(operators, "OBS", &bucket, || {
-                    super::obs_config_build(config, path)
+                    super::obs_config_build(config, path, user_agent)
                 })?;
                 Ok((op, Cow::Borrowed(relative_path)))
             }
             #[cfg(feature = "storage-gcs")]
-            Storage::Gcs { config, operators } => {
+            Storage::Gcs {
+                config,
+                user_agent,
+                operators,
+            } => {
                 let (bucket, relative_path) =
                     Self::bucket_and_relative_path(path, "GCS", &["gcs", 
"gs"])?;
                 let op = Self::cached_operator(operators, "GCS", &bucket, || {
-                    super::gcs_config_build(config, path)
+                    super::gcs_config_build(config, path, user_agent)
                 })?;
                 Ok((op, Cow::Borrowed(relative_path)))
             }
@@ -460,12 +495,13 @@ impl Storage {
     #[cfg(feature = "storage-s3")]
     fn cached_s3_operator(
         config: &S3Config,
+        user_agent: &str,
         operators: &Mutex<HashMap<String, Operator>>,
         path: &str,
         bucket: &str,
     ) -> crate::Result<Operator> {
         Self::cached_operator(operators, "S3", bucket, || {
-            super::s3_config_build(config, path)
+            super::s3_config_build(config, path, user_agent)
         })
     }
 }
@@ -525,6 +561,28 @@ mod scheme_tests {
         }
     }
 
+    #[cfg(feature = "storage-s3")]
+    #[test]
+    fn s3_storage_uses_generic_user_agent_keys() {
+        let storage = Storage::build(FileIOBuilder::new("s3").with_props([
+            ("user-agent.features", "Flink"),
+            ("user-agent.extended", "vvr"),
+            ("dlf.access-tracking.extended-info", "uid/123"),
+        ]))
+        .unwrap();
+        let Storage::S3 { user_agent, .. } = storage else {
+            panic!("expected S3 storage");
+        };
+        assert_eq!(
+            user_agent,
+            format!(
+                "paimon-rust/{}(opendal/{};Flink) vvr uid/123",
+                env!("CARGO_PKG_VERSION"),
+                opendal::raw::VERSION
+            )
+        );
+    }
+
     #[cfg(feature = "storage-cos")]
     #[test]
     fn cos_scheme_aliases_are_compatible() {
diff --git a/crates/paimon/src/io/storage_azdls.rs 
b/crates/paimon/src/io/storage_azdls.rs
index 6551f617..9bcde5c4 100644
--- a/crates/paimon/src/io/storage_azdls.rs
+++ b/crates/paimon/src/io/storage_azdls.rs
@@ -64,11 +64,15 @@ pub(crate) fn azdls_config_parse(props: HashMap<String, 
String>) -> Result<Azdls
     Ok(AzdlsStorageConfig { config, normalized })
 }
 
-pub(crate) fn azdls_config_build(cfg: &AzdlsStorageConfig, path: &str) -> 
Result<Operator> {
+pub(crate) fn azdls_config_build(
+    cfg: &AzdlsStorageConfig,
+    path: &str,
+    user_agent: &str,
+) -> Result<Operator> {
     let (cfg, relative_path) = azdls_config_for_path(cfg, path)?;
 
     let builder = cfg.into_builder();
-    let op = super::with_http_transport(Operator::new(builder)?);
+    let op = super::with_http_transport(Operator::new(builder)?, user_agent);
 
     debug_assert_eq!(
         relative_path,
@@ -394,14 +398,19 @@ mod tests {
     fn test_azdls_config_build_hadoop_form() {
         let cfg = azdls_config_parse(HashMap::new()).unwrap();
 
-        let op = azdls_config_build(&cfg, 
"abfs://[email protected]/a/b").unwrap();
+        let op = azdls_config_build(
+            &cfg,
+            "abfs://[email protected]/a/b",
+            "paimon-rust/test",
+        )
+        .unwrap();
         assert_eq!(op.info().name(), "fs");
     }
 
     #[test]
     fn test_azdls_config_build_fsspec_form_requires_endpoint() {
         let cfg = azdls_config_parse(HashMap::new()).unwrap();
-        let result = azdls_config_build(&cfg, "abfs://fs/a/b");
+        let result = azdls_config_build(&cfg, "abfs://fs/a/b", 
"paimon-rust/test");
         assert!(result.is_err());
     }
 
@@ -413,7 +422,7 @@ mod tests {
         )]))
         .unwrap();
 
-        let op = azdls_config_build(&cfg, "abfs://fs/a/b").unwrap();
+        let op = azdls_config_build(&cfg, "abfs://fs/a/b", 
"paimon-rust/test").unwrap();
         assert_eq!(op.info().name(), "fs");
     }
 
@@ -439,7 +448,8 @@ mod tests {
     #[test]
     fn test_azdls_config_build_missing_filesystem() {
         let cfg = azdls_config_parse(HashMap::new()).unwrap();
-        let result = azdls_config_build(&cfg, 
"abfs:///path/without/filesystem");
+        let result =
+            azdls_config_build(&cfg, "abfs:///path/without/filesystem", 
"paimon-rust/test");
         assert!(result.is_err());
     }
 }
diff --git a/crates/paimon/src/io/storage_cos.rs 
b/crates/paimon/src/io/storage_cos.rs
index 61ea159a..968841eb 100644
--- a/crates/paimon/src/io/storage_cos.rs
+++ b/crates/paimon/src/io/storage_cos.rs
@@ -59,7 +59,7 @@ pub(crate) fn cos_config_parse(props: HashMap<String, 
String>) -> Result<CosConf
     Ok(cfg)
 }
 
-pub(crate) fn cos_config_build(cfg: &CosConfig, path: &str) -> 
Result<Operator> {
+pub(crate) fn cos_config_build(cfg: &CosConfig, path: &str, user_agent: &str) 
-> Result<Operator> {
     let url = Url::parse(path).map_err(|_| Error::ConfigInvalid {
         message: format!("Invalid COS url: {path}"),
     })?;
@@ -69,7 +69,10 @@ pub(crate) fn cos_config_build(cfg: &CosConfig, path: &str) 
-> Result<Operator>
     })?;
 
     let builder = cfg.clone().into_builder().bucket(bucket);
-    Ok(super::with_http_transport(Operator::new(builder)?))
+    Ok(super::with_http_transport(
+        Operator::new(builder)?,
+        user_agent,
+    ))
 }
 
 #[cfg(test)]
@@ -124,14 +127,14 @@ mod tests {
             ..Default::default()
         };
 
-        let op = cos_config_build(&cfg, "cosn://my-bucket/some/path").unwrap();
+        let op = cos_config_build(&cfg, "cosn://my-bucket/some/path", 
"paimon-rust/test").unwrap();
         assert_eq!(op.info().name(), "my-bucket");
     }
 
     #[test]
     fn test_cos_config_build_missing_bucket() {
         let cfg = CosConfig::default();
-        let result = cos_config_build(&cfg, "cosn:///path/without/bucket");
+        let result = cos_config_build(&cfg, "cosn:///path/without/bucket", 
"paimon-rust/test");
         assert!(result.is_err());
     }
 }
diff --git a/crates/paimon/src/io/storage_gcs.rs 
b/crates/paimon/src/io/storage_gcs.rs
index 487ede59..dccb34e9 100644
--- a/crates/paimon/src/io/storage_gcs.rs
+++ b/crates/paimon/src/io/storage_gcs.rs
@@ -81,7 +81,7 @@ pub(crate) fn gcs_config_parse(props: HashMap<String, 
String>) -> Result<GcsConf
     Ok(cfg)
 }
 
-pub(crate) fn gcs_config_build(cfg: &GcsConfig, path: &str) -> 
Result<Operator> {
+pub(crate) fn gcs_config_build(cfg: &GcsConfig, path: &str, user_agent: &str) 
-> Result<Operator> {
     let url = Url::parse(path).map_err(|_| Error::ConfigInvalid {
         message: format!("Invalid GCS url: {path}"),
     })?;
@@ -91,7 +91,10 @@ pub(crate) fn gcs_config_build(cfg: &GcsConfig, path: &str) 
-> Result<Operator>
     })?;
 
     let builder = cfg.clone().into_builder().bucket(bucket);
-    Ok(super::with_http_transport(Operator::new(builder)?))
+    Ok(super::with_http_transport(
+        Operator::new(builder)?,
+        user_agent,
+    ))
 }
 
 #[cfg(test)]
@@ -185,14 +188,14 @@ mod tests {
     fn test_gcs_config_build_extracts_bucket() {
         let cfg = GcsConfig::default();
 
-        let op = gcs_config_build(&cfg, "gs://my-bucket/some/path").unwrap();
+        let op = gcs_config_build(&cfg, "gs://my-bucket/some/path", 
"paimon-rust/test").unwrap();
         assert_eq!(op.info().name(), "my-bucket");
     }
 
     #[test]
     fn test_gcs_config_build_missing_bucket() {
         let cfg = GcsConfig::default();
-        let result = gcs_config_build(&cfg, "gs:///path/without/bucket");
+        let result = gcs_config_build(&cfg, "gs:///path/without/bucket", 
"paimon-rust/test");
         assert!(result.is_err());
     }
 }
diff --git a/crates/paimon/src/io/storage_obs.rs 
b/crates/paimon/src/io/storage_obs.rs
index a64a97f2..42891d32 100644
--- a/crates/paimon/src/io/storage_obs.rs
+++ b/crates/paimon/src/io/storage_obs.rs
@@ -53,7 +53,7 @@ pub(crate) fn obs_config_parse(props: HashMap<String, 
String>) -> Result<ObsConf
     Ok(cfg)
 }
 
-pub(crate) fn obs_config_build(cfg: &ObsConfig, path: &str) -> 
Result<Operator> {
+pub(crate) fn obs_config_build(cfg: &ObsConfig, path: &str, user_agent: &str) 
-> Result<Operator> {
     let url = Url::parse(path).map_err(|_| Error::ConfigInvalid {
         message: format!("Invalid OBS url: {path}"),
     })?;
@@ -63,7 +63,10 @@ pub(crate) fn obs_config_build(cfg: &ObsConfig, path: &str) 
-> Result<Operator>
     })?;
 
     let builder = cfg.clone().into_builder().bucket(bucket);
-    Ok(super::with_http_transport(Operator::new(builder)?))
+    Ok(super::with_http_transport(
+        Operator::new(builder)?,
+        user_agent,
+    ))
 }
 
 #[cfg(test)]
@@ -120,14 +123,14 @@ mod tests {
         let mut cfg = ObsConfig::default();
         cfg.endpoint = 
Some("https://obs.cn-north-4.myhuaweicloud.com".to_string());
 
-        let op = obs_config_build(&cfg, "obs://my-bucket/some/path").unwrap();
+        let op = obs_config_build(&cfg, "obs://my-bucket/some/path", 
"paimon-rust/test").unwrap();
         assert_eq!(op.info().name(), "my-bucket");
     }
 
     #[test]
     fn test_obs_config_build_missing_bucket() {
         let cfg = ObsConfig::default();
-        let result = obs_config_build(&cfg, "obs:///path/without/bucket");
+        let result = obs_config_build(&cfg, "obs:///path/without/bucket", 
"paimon-rust/test");
         assert!(result.is_err());
     }
 }
diff --git a/crates/paimon/src/io/storage_oss.rs 
b/crates/paimon/src/io/storage_oss.rs
index 83923d70..17b99bea 100644
--- a/crates/paimon/src/io/storage_oss.rs
+++ b/crates/paimon/src/io/storage_oss.rs
@@ -61,16 +61,18 @@ pub struct OssStorageConfig {
     service: OssConfig,
     retry_count: usize,
     retry_interval: Duration,
+    user_agent: String,
 }
 
 /// Parse paimon catalog options into an [`OssStorageConfig`].
 ///
 /// Extracts OSS-related configuration keys (endpoint, access key, secret key,
-/// optional security token, and retry settings) from the provided properties.
+/// optional security token, retry and User-Agent settings) from the provided 
properties.
 ///
 /// Returns an error if any required configuration key is missing.
 pub(crate) fn oss_config_parse(mut props: HashMap<String, String>) -> 
Result<OssStorageConfig> {
     let mut cfg = OssConfig::default();
+    let user_agent = super::user_agent::oss_user_agent(&props);
 
     cfg.endpoint = Some(
         props
@@ -107,6 +109,7 @@ pub(crate) fn oss_config_parse(mut props: HashMap<String, 
String>) -> Result<Oss
         service: cfg,
         retry_count,
         retry_interval: Duration::from_millis(retry_interval_millis),
+        user_agent,
     })
 }
 
@@ -140,17 +143,17 @@ pub(crate) fn oss_config_build(cfg: &OssStorageConfig, 
path: &str) -> Result<Ope
         .with_min_delay(cfg.retry_interval)
         .with_max_times(cfg.retry_count)
         .with_jitter();
-    Ok(super::with_http_transport(Operator::new(builder)?).layer(retry))
+    Ok(super::with_http_transport(Operator::new(builder)?, 
&cfg.user_agent).layer(retry))
 }
 
 #[cfg(test)]
 mod tests {
     use std::sync::atomic::{AtomicUsize, Ordering};
-    use std::sync::Arc;
+    use std::sync::{Arc, Mutex};
 
     use axum::body::Body;
     use axum::extract::State;
-    use axum::http::{Response, StatusCode};
+    use axum::http::{header, HeaderMap, Response, StatusCode};
     use axum::routing::get;
     use axum::Router;
 
@@ -161,6 +164,7 @@ mod tests {
             service,
             retry_count: DEFAULT_OSS_RETRY_COUNT,
             retry_interval: Duration::from_millis(1),
+            user_agent: crate::io::user_agent::default_user_agent(),
         }
     }
 
@@ -196,6 +200,18 @@ mod tests {
         temporary_failure()
     }
 
+    async fn record_user_agent(
+        State(user_agents): State<Arc<Mutex<Vec<String>>>>,
+        headers: HeaderMap,
+    ) -> StatusCode {
+        let user_agent = headers
+            .get(header::USER_AGENT)
+            .map(|value| value.to_str().unwrap().to_string())
+            .unwrap_or_default();
+        user_agents.lock().unwrap().push(user_agent);
+        StatusCode::NOT_FOUND
+    }
+
     #[test]
     fn test_oss_config_parse_with_all_keys() {
         let mut props = required_props();
@@ -292,4 +308,44 @@ mod tests {
         assert!(op.read("object").await.is_err());
         assert_eq!(attempts.load(Ordering::SeqCst), 2);
     }
+
+    #[tokio::test]
+    async fn test_oss_sends_configured_user_agent() {
+        use crate::io::user_agent::{
+            DLF_ACCESS_TRACKING_EXTENDED_INFO, OSS_USER_AGENT_EXTENDED, 
USER_AGENT_EXTENDED,
+            USER_AGENT_FEATURES,
+        };
+
+        let user_agents = Arc::new(Mutex::new(Vec::new()));
+        let app = Router::new()
+            .fallback(record_user_agent)
+            .with_state(user_agents.clone());
+        let listener = 
tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
+        let address = listener.local_addr().unwrap();
+        tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
+
+        let mut props = required_props();
+        props.insert(OSS_ENDPOINT.to_string(), format!("http://{address}";));
+        props.insert(USER_AGENT_FEATURES.to_string(), 
"DataFusion".to_string());
+        props.insert(USER_AGENT_EXTENDED.to_string(), 
"overridden".to_string());
+        props.insert(OSS_USER_AGENT_EXTENDED.to_string(), "vvr".to_string());
+        props.insert(
+            DLF_ACCESS_TRACKING_EXTENDED_INFO.to_string(),
+            "uid/123".to_string(),
+        );
+        let mut cfg = oss_config_parse(props).unwrap();
+        cfg.service.addressing_style = Some("path".to_string());
+        cfg.service.skip_signature = true;
+
+        let op = oss_config_build(&cfg, "oss://bucket/path").unwrap();
+        assert!(!op.exists("object").await.unwrap());
+        assert_eq!(
+            *user_agents.lock().unwrap(),
+            vec![format!(
+                "paimon-rust/{}(opendal/{};DataFusion) vvr uid/123",
+                env!("CARGO_PKG_VERSION"),
+                opendal::raw::VERSION
+            )]
+        );
+    }
 }
diff --git a/crates/paimon/src/io/storage_s3.rs 
b/crates/paimon/src/io/storage_s3.rs
index 8d77a365..7aaaa0c3 100644
--- a/crates/paimon/src/io/storage_s3.rs
+++ b/crates/paimon/src/io/storage_s3.rs
@@ -128,7 +128,7 @@ pub(crate) fn s3_config_parse(props: HashMap<String, 
String>) -> Result<S3Config
 /// Parses the bucket name from the `s3://bucket/key` or `s3a://bucket/key`
 /// URL and combines it with the provided [`S3Config`] to construct an
 /// OpenDAL operator.
-pub(crate) fn s3_config_build(cfg: &S3Config, path: &str) -> Result<Operator> {
+pub(crate) fn s3_config_build(cfg: &S3Config, path: &str, user_agent: &str) -> 
Result<Operator> {
     let url = Url::parse(path).map_err(|_| Error::ConfigInvalid {
         message: format!("Invalid S3 url: {path}"),
     })?;
@@ -138,7 +138,10 @@ pub(crate) fn s3_config_build(cfg: &S3Config, path: &str) 
-> Result<Operator> {
     })?;
 
     let builder = cfg.clone().into_builder().bucket(bucket);
-    Ok(super::with_http_transport(Operator::new(builder)?))
+    Ok(super::with_http_transport(
+        Operator::new(builder)?,
+        user_agent,
+    ))
 }
 
 #[cfg(test)]
@@ -309,7 +312,7 @@ mod tests {
         cfg.endpoint = Some("https://s3.us-east-1.amazonaws.com".to_string());
         cfg.region = Some("us-east-1".to_string());
 
-        let op = s3_config_build(&cfg, "s3://my-bucket/some/path").unwrap();
+        let op = s3_config_build(&cfg, "s3://my-bucket/some/path", 
"paimon-rust/test").unwrap();
         assert_eq!(op.info().name(), "my-bucket");
     }
 
@@ -320,21 +323,21 @@ mod tests {
         cfg.endpoint = Some("https://s3.us-east-1.amazonaws.com".to_string());
         cfg.region = Some("us-east-1".to_string());
 
-        let op = s3_config_build(&cfg, "s3a://my-bucket/some/path").unwrap();
+        let op = s3_config_build(&cfg, "s3a://my-bucket/some/path", 
"paimon-rust/test").unwrap();
         assert_eq!(op.info().name(), "my-bucket");
     }
 
     #[test]
     fn test_s3_config_build_invalid_url() {
         let cfg = S3Config::default();
-        let result = s3_config_build(&cfg, "not-a-valid-url");
+        let result = s3_config_build(&cfg, "not-a-valid-url", 
"paimon-rust/test");
         assert!(result.is_err());
     }
 
     #[test]
     fn test_s3_config_build_missing_bucket() {
         let cfg = S3Config::default();
-        let result = s3_config_build(&cfg, "s3:///path/without/bucket");
+        let result = s3_config_build(&cfg, "s3:///path/without/bucket", 
"paimon-rust/test");
         assert!(result.is_err());
     }
 
diff --git a/crates/paimon/src/io/user_agent.rs 
b/crates/paimon/src/io/user_agent.rs
new file mode 100644
index 00000000..8bb21e2c
--- /dev/null
+++ b/crates/paimon/src/io/user_agent.rs
@@ -0,0 +1,291 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! Paimon's unified User-Agent for object storage requests:
+//! `<module>(<transport>[;feature...])[ <extended>]`.
+
+use std::collections::HashMap;
+
+use http::header::{HeaderValue, USER_AGENT};
+use http::{Request, Response};
+use opendal::{Buffer, HttpBody, HttpTransport};
+use opendal_http_transport_reqwest::ReqwestTransport;
+
+/// Replaces the default `paimon-rust/<version>` module; shared with REST 
requests.
+pub(crate) const USER_AGENT_MODULE: &str = "user-agent.module";
+
+/// Space-separated features, rendered after the transport and separated by 
`;`.
+pub(crate) const USER_AGENT_FEATURES: &str = "user-agent.features";
+
+/// Free text after the parentheses.
+pub(crate) const USER_AGENT_EXTENDED: &str = "user-agent.extended";
+
+/// OSS-only keys, each overriding the matching `user-agent.*` key.
+pub(crate) const OSS_USER_AGENT_MODULE: &str = "fs.oss.user.agent.module";
+pub(crate) const OSS_USER_AGENT_FEATURES: &str = "fs.oss.user.agent.features";
+pub(crate) const OSS_USER_AGENT_EXTENDED: &str = "fs.oss.user.agent.extended";
+
+/// Set by the DLF data token and appended after the extended part.
+pub(crate) const DLF_ACCESS_TRACKING_EXTENDED_INFO: &str = 
"dlf.access-tracking.extended-info";
+
+/// The User-Agent sent when no `user-agent.*` option is set.
+pub(crate) fn default_user_agent() -> String {
+    storage_user_agent(&HashMap::new())
+}
+
+/// Builds the User-Agent from the `user-agent.*` options.
+pub(crate) fn storage_user_agent(props: &HashMap<String, String>) -> String {
+    build_user_agent(props, None)
+}
+
+/// Builds the OSS User-Agent, where `fs.oss.user.agent.*` overrides 
`user-agent.*` per part.
+pub(crate) fn oss_user_agent(props: &HashMap<String, String>) -> String {
+    build_user_agent(
+        props,
+        Some([
+            OSS_USER_AGENT_MODULE,
+            OSS_USER_AGENT_FEATURES,
+            OSS_USER_AGENT_EXTENDED,
+        ]),
+    )
+}
+
+fn build_user_agent(props: &HashMap<String, String>, overrides: Option<[&str; 
3]>) -> String {
+    let non_blank = |key: &str| {
+        props
+            .get(key)
+            .map(|value| value.trim())
+            .filter(|value| !value.is_empty())
+    };
+    let part = |index: usize, key: &str| {
+        overrides
+            .and_then(|keys| non_blank(keys[index]))
+            .or_else(|| non_blank(key))
+    };
+
+    let mut user_agent = match part(0, USER_AGENT_MODULE) {
+        Some(module) => module.to_string(),
+        None => format!("paimon-rust/{}", env!("CARGO_PKG_VERSION")),
+    };
+    user_agent.push_str("(opendal/");
+    user_agent.push_str(opendal::raw::VERSION);
+    for feature in part(1, USER_AGENT_FEATURES)
+        .into_iter()
+        .flat_map(|features| features.split_whitespace())
+    {
+        user_agent.push(';');
+        user_agent.push_str(feature);
+    }
+    user_agent.push(')');
+
+    let extended: Vec<&str> = [
+        part(2, USER_AGENT_EXTENDED),
+        non_blank(DLF_ACCESS_TRACKING_EXTENDED_INFO),
+    ]
+    .into_iter()
+    .flatten()
+    .collect();
+    if !extended.is_empty() {
+        user_agent.push(' ');
+        user_agent.push_str(&extended.join(" "));
+    }
+    user_agent
+}
+
+/// Sends every request through the shared reqwest client with a User-Agent, 
unless one is set.
+#[derive(Clone)]
+pub(crate) struct UserAgentTransport {
+    user_agent: HeaderValue,
+    inner: ReqwestTransport,
+}
+
+impl UserAgentTransport {
+    pub(crate) fn new(user_agent: &str) -> Self {
+        let user_agent = 
HeaderValue::from_bytes(user_agent.as_bytes()).unwrap_or_else(|_| {
+            log::warn!("Invalid object storage User-Agent {user_agent:?}, 
using the default");
+            HeaderValue::from_str(&default_user_agent())
+                .expect("the default User-Agent is visible ASCII")
+        });
+        Self {
+            user_agent,
+            inner: ReqwestTransport::default(),
+        }
+    }
+
+    fn apply(&self, req: &mut Request<Buffer>) {
+        req.headers_mut()
+            .entry(USER_AGENT)
+            .or_insert_with(|| self.user_agent.clone());
+    }
+}
+
+impl HttpTransport for UserAgentTransport {
+    async fn fetch(&self, mut req: Request<Buffer>) -> 
opendal::Result<Response<HttpBody>> {
+        self.apply(&mut req);
+        self.inner.fetch(req).await
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn props(entries: &[(&str, &str)]) -> HashMap<String, String> {
+        entries
+            .iter()
+            .map(|(key, value)| (key.to_string(), value.to_string()))
+            .collect()
+    }
+
+    fn default_prefix() -> String {
+        format!(
+            "paimon-rust/{}(opendal/{}",
+            env!("CARGO_PKG_VERSION"),
+            opendal::raw::VERSION
+        )
+    }
+
+    #[test]
+    fn test_default_user_agent() {
+        assert_eq!(default_user_agent(), format!("{})", default_prefix()));
+    }
+
+    #[test]
+    fn test_module_override() {
+        let user_agent = oss_user_agent(&props(&[(OSS_USER_AGENT_MODULE, 
"MyApp/1.0")]));
+        assert_eq!(
+            user_agent,
+            format!("MyApp/1.0(opendal/{})", opendal::raw::VERSION)
+        );
+    }
+
+    #[test]
+    fn test_features_are_split_and_joined() {
+        let user_agent = oss_user_agent(&props(&[(OSS_USER_AGENT_FEATURES, " 
Flink  Paimon ")]));
+        assert_eq!(user_agent, format!("{};Flink;Paimon)", default_prefix()));
+    }
+
+    #[test]
+    fn test_access_tracking_is_appended_to_user_extended() {
+        let user_agent = oss_user_agent(&props(&[
+            (OSS_USER_AGENT_EXTENDED, "vvr"),
+            (DLF_ACCESS_TRACKING_EXTENDED_INFO, "uid/123 user/alice"),
+        ]));
+        assert_eq!(
+            user_agent,
+            format!("{}) vvr uid/123 user/alice", default_prefix())
+        );
+    }
+
+    #[test]
+    fn test_access_tracking_only() {
+        let user_agent = oss_user_agent(&props(&[(
+            DLF_ACCESS_TRACKING_EXTENDED_INFO,
+            "uid/123 user/alice",
+        )]));
+        assert_eq!(
+            user_agent,
+            format!("{}) uid/123 user/alice", default_prefix())
+        );
+    }
+
+    #[test]
+    fn test_blank_options_are_ignored() {
+        let user_agent = oss_user_agent(&props(&[
+            (OSS_USER_AGENT_MODULE, " "),
+            (OSS_USER_AGENT_FEATURES, "  "),
+            (OSS_USER_AGENT_EXTENDED, ""),
+            (USER_AGENT_MODULE, " "),
+            (USER_AGENT_FEATURES, ""),
+            (USER_AGENT_EXTENDED, " "),
+            (DLF_ACCESS_TRACKING_EXTENDED_INFO, " "),
+        ]));
+        assert_eq!(user_agent, default_user_agent());
+    }
+
+    #[test]
+    fn test_generic_keys_apply_to_any_backend() {
+        let user_agent = storage_user_agent(&props(&[
+            (USER_AGENT_MODULE, "MyApp/1.0"),
+            (USER_AGENT_FEATURES, "Flink"),
+            (USER_AGENT_EXTENDED, "vvr"),
+            (DLF_ACCESS_TRACKING_EXTENDED_INFO, "uid/123"),
+            (OSS_USER_AGENT_EXTENDED, "ignored"),
+        ]));
+        assert_eq!(
+            user_agent,
+            format!(
+                "MyApp/1.0(opendal/{};Flink) vvr uid/123",
+                opendal::raw::VERSION
+            )
+        );
+    }
+
+    #[test]
+    fn test_oss_falls_back_to_generic_keys() {
+        let user_agent = oss_user_agent(&props(&[
+            (USER_AGENT_FEATURES, "Flink"),
+            (USER_AGENT_EXTENDED, "vvr"),
+            (DLF_ACCESS_TRACKING_EXTENDED_INFO, "uid/123"),
+        ]));
+        assert_eq!(
+            user_agent,
+            format!("{};Flink) vvr uid/123", default_prefix())
+        );
+    }
+
+    #[test]
+    fn test_oss_keys_override_generic_keys_per_part() {
+        let user_agent = oss_user_agent(&props(&[
+            (USER_AGENT_MODULE, "Generic/1.0"),
+            (USER_AGENT_FEATURES, "Flink"),
+            (USER_AGENT_EXTENDED, "vvr"),
+            (OSS_USER_AGENT_FEATURES, "Spark"),
+            (OSS_USER_AGENT_EXTENDED, "oss/ext"),
+            (DLF_ACCESS_TRACKING_EXTENDED_INFO, "uid/123"),
+        ]));
+        assert_eq!(
+            user_agent,
+            format!(
+                "Generic/1.0(opendal/{};Spark) oss/ext uid/123",
+                opendal::raw::VERSION
+            )
+        );
+    }
+
+    #[test]
+    fn test_existing_user_agent_is_kept() {
+        let transport = UserAgentTransport::new("paimon-rust/test");
+
+        let mut req = Request::new(Buffer::new());
+        transport.apply(&mut req);
+        assert_eq!(req.headers()[USER_AGENT], "paimon-rust/test");
+
+        let mut req = Request::builder()
+            .header(USER_AGENT, "caller/1.0")
+            .body(Buffer::new())
+            .unwrap();
+        transport.apply(&mut req);
+        assert_eq!(req.headers()[USER_AGENT], "caller/1.0");
+    }
+
+    #[test]
+    fn test_invalid_user_agent_falls_back_to_default() {
+        let transport = UserAgentTransport::new("bad\nagent");
+        assert_eq!(transport.user_agent, default_user_agent().as_str());
+    }
+}

Reply via email to