From 5c452f8f33d9f4bcefefb34ddab03dc9bf433506 Mon Sep 17 00:00:00 2001 From: Austin Bonander Date: Thu, 1 Oct 2026 12:01:16 -0700 Subject: [PATCH 1/4] fix: enforce expected ordering in product info added by `clickhouse-ext-arrow` --- Cargo.toml | 2 +- ext-arrow/CHANGELOG.md | 5 +++++ ext-arrow/Cargo.toml | 2 +- ext-arrow/clippy.toml | 14 ++++++++++++++ ext-arrow/src/lib.rs | 6 +++--- src/headers.rs | 41 +++++++++++++++++++++++++++-------------- src/insert_formatted.rs | 23 ++++++++++++++++++++++- src/insert_native.rs | 4 ++-- src/lib.rs | 20 ++++++++++++++++---- src/query.rs | 23 +++++++++++++++++++++-- tests/it/arrow.rs | 7 +++++-- 11 files changed, 117 insertions(+), 30 deletions(-) create mode 100644 ext-arrow/clippy.toml diff --git a/Cargo.toml b/Cargo.toml index 11b26a2e..5c5a438c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "clickhouse" -version = "0.15.2" +version = "0.15.3" description = "Official Rust client for ClickHouse DB" keywords = ["clickhouse", "database", "driver", "tokio", "hyper"] readme = "README.md" diff --git a/ext-arrow/CHANGELOG.md b/ext-arrow/CHANGELOG.md index b2b46163..9e54fe70 100644 --- a/ext-arrow/CHANGELOG.md +++ b/ext-arrow/CHANGELOG.md @@ -8,6 +8,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] - ReleaseDate +### Fixed + +* Changed how product info is added internally so it doesn't end up in the user agent string ahead of any user-added + product info. + ## [0.1.0] - 2026-06-01 Initial release. diff --git a/ext-arrow/Cargo.toml b/ext-arrow/Cargo.toml index 658bd417..e1558a38 100644 --- a/ext-arrow/Cargo.toml +++ b/ext-arrow/Cargo.toml @@ -22,5 +22,5 @@ tokio = "1" tracing = "0.1" [dependencies.clickhouse] -version = "0.15.1" +version = "0.15.3" path = ".." diff --git a/ext-arrow/clippy.toml b/ext-arrow/clippy.toml new file mode 100644 index 00000000..9ab4f7b9 --- /dev/null +++ b/ext-arrow/clippy.toml @@ -0,0 +1,14 @@ +[[disallowed-methods]] +path = "clickhouse::Client::with_product_info" +reason = "will place product info at front of user agent string, ahead of the app's product info" +replacement = "clickhouse::Client::with_stack_product_info" + +[[disallowed-methods]] +path = "clickhouse::query::Query::with_product_info" +reason = "will place product info at front of user agent string, ahead of the app's product info" +replacement = "clickhouse::query::Query::with_stack_product_info" + +[[disallowed-methods]] +path = "clickhouse::insert_formatted::InsertFormatted::with_product_info" +reason = "will place product info at front of user agent string, ahead of the app's product info" +replacement = "clickhouse::insert_formatted::InsertFormatted::with_stack_product_info" diff --git a/ext-arrow/src/lib.rs b/ext-arrow/src/lib.rs index 08bcd8b9..61fbfc86 100644 --- a/ext-arrow/src/lib.rs +++ b/ext-arrow/src/lib.rs @@ -117,7 +117,7 @@ impl ArrowQueryExt for Query { // Prevent ClickHouse from double-compressing .with_setting("output_format_arrow_compression_method", "none") // Add specific product info to let us track Arrow adoption - .with_product_info("clickhouse-ext-arrow", _priv::CARGO_PKG_VERSION) + .with_stack_product_info("clickhouse-ext-arrow", _priv::CARGO_PKG_VERSION) .fetch_bytes("ArrowStream")?, buffer: Buffer::default(), decoder: StreamDecoder::new(), @@ -287,7 +287,7 @@ impl InsertState { let insert = client .insert_formatted_with(query_string) // Add specific product info to let us track Arrow adoption - .with_product_info("clickhouse-ext-arrow", _priv::CARGO_PKG_VERSION) + .with_stack_product_info("clickhouse-ext-arrow", _priv::CARGO_PKG_VERSION) .buffered(); tracing::record_all!(insert._priv_span(), db.collection.name = table); @@ -302,7 +302,7 @@ impl InsertState { let insert = client .insert_formatted_with(sql) // Add specific product info to let us track Arrow adoption - .with_product_info("clickhouse-ext-arrow", _priv::CARGO_PKG_VERSION) + .with_stack_product_info("clickhouse-ext-arrow", _priv::CARGO_PKG_VERSION) .buffered(); *self = Self::Started( diff --git a/src/headers.rs b/src/headers.rs index 8d877e1c..fd5de2e4 100644 --- a/src/headers.rs +++ b/src/headers.rs @@ -4,29 +4,39 @@ use hyper::http::request::Builder; use std::collections::HashMap; use std::env::consts::OS; -fn get_user_agent(products_info: &[ProductInfo]) -> String { +fn get_user_agent(app_product_info: &[ProductInfo], stack_product_info: &[ProductInfo]) -> String { + use std::fmt::Write; + // See https://doc.rust-lang.org/cargo/reference/environment-variables.html#environment-variables-cargo-sets-for-crates let pkg_ver = option_env!("CARGO_PKG_VERSION").unwrap_or("unknown"); let rust_ver = option_env!("CARGO_PKG_RUST_VERSION").unwrap_or("unknown"); - let default_agent = format!("clickhouse-rs/{pkg_ver} (lv:rust/{rust_ver}; os:{OS})"); - if products_info.is_empty() { - default_agent - } else { - let products = products_info - .iter() - .rev() - .map(|product_info| product_info.to_string()) - .collect::>() - .join(" "); - format!("{products} {default_agent}") + + let mut infix = ""; + + let mut user_agent = String::new(); + + for product_info in stack_product_info.iter().chain(app_product_info).rev() { + write!(user_agent, "{infix}{product_info}") + .expect("BUG: formatting to a string should be infallible"); + + infix = " "; } + + write!( + user_agent, + "{infix}clickhouse-rs/{pkg_ver} (lv:rust/{rust_ver}; os:{OS})" + ) + .expect("BUG: formatting to a string should be infallible"); + + user_agent } #[inline] pub(crate) fn with_request_headers( mut builder: Builder, headers: &HashMap, - products_info: &[ProductInfo], + app_product_info: &[ProductInfo], + stack_product_info: &[ProductInfo], ) -> Builder { // Inject the OpenTelemetry trace context if the feature is enabled #[cfg(feature = "opentelemetry")] @@ -46,7 +56,10 @@ pub(crate) fn with_request_headers( for (name, value) in headers { builder = builder.header(name, value); } - builder = builder.header(USER_AGENT.to_string(), get_user_agent(products_info)); + builder = builder.header( + USER_AGENT.to_string(), + get_user_agent(app_product_info, stack_product_info), + ); builder } diff --git a/src/insert_formatted.rs b/src/insert_formatted.rs index cd53e3da..6933de96 100644 --- a/src/insert_formatted.rs +++ b/src/insert_formatted.rs @@ -275,6 +275,22 @@ impl InsertFormatted { self } + // NOTE: SemVer-exempt API. + // Used in `clickhouse-ext-arrow` to track Arrow adoption. + // A separate method is necessary to ensure this doesn't end up ahead of the end-user's + // product info in the user agent string. + #[doc(hidden)] + pub fn with_stack_product_info( + mut self, + product_name: impl Into, + product_version: impl Into, + ) -> Self { + self.state + .expect_client_mut() + .add_stack_product_info(product_name.into(), product_version.into()); + self + } + pub(crate) fn expect_client(&self) -> &Client { self.state.expect_client() } @@ -497,7 +513,12 @@ impl InsertFormatted { drop(pairs); let mut builder = Request::post(url.as_str()); - builder = with_request_headers(builder, &client.headers, &client.products_info); + builder = with_request_headers( + builder, + &client.headers, + &client.app_product_info, + &client.stack_product_info, + ); builder = with_authentication(builder, &client.authentication); let (sender, body) = RequestBody::chunked(); diff --git a/src/insert_native.rs b/src/insert_native.rs index 35e34cbb..9c1b4bad 100644 --- a/src/insert_native.rs +++ b/src/insert_native.rs @@ -189,7 +189,7 @@ mod tests { let insert = client.insert_native("foo"); - let product_info = &insert.writer.expect_client().products_info; + let product_info = &insert.writer.expect_client().app_product_info; assert!( product_info.is_empty(), "unexpected product_info: {product_info:?}" @@ -199,7 +199,7 @@ mod tests { .with_product_info("foo", "1.0.0") .with_product_info("bar", "0.1.0-alpha.1"); - let product_info = &insert.writer.expect_client().products_info; + let product_info = &insert.writer.expect_client().app_product_info; assert_eq!( *product_info, diff --git a/src/lib.rs b/src/lib.rs index a5fc4734..e8a8d32c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -66,7 +66,10 @@ pub struct Client { roles: HashSet, settings: HashMap, headers: HashMap, - products_info: Vec, + // Separate product info added by the user from product info added by the stack + // (e.g. `clickhouse-ext-arrow`) so it appears in the right order in logs. + app_product_info: Vec, + stack_product_info: Vec, validation: bool, insert_metadata_cache: Arc, @@ -134,7 +137,8 @@ impl std::fmt::Debug for Client { .field("roles", &self.roles) .field("settings", &self.settings) .field("headers", &self.headers.keys()) // redact values - .field("products_info", &self.products_info) + .field("app_product_info", &self.app_product_info) + .field("stack_product_info", &self.stack_product_info) .field("validation", &self.validation) .finish_non_exhaustive() } @@ -159,7 +163,8 @@ impl Client { roles: HashSet::new(), settings: HashMap::new(), headers: HashMap::new(), - products_info: Vec::default(), + app_product_info: Vec::default(), + stack_product_info: Vec::default(), validation: true, insert_metadata_cache: Arc::new(InsertMetadataCache::default()), #[cfg(feature = "test-util")] @@ -444,7 +449,14 @@ impl Client { } pub(crate) fn add_product_info(&mut self, product_name: String, product_version: String) { - self.products_info.push(ProductInfo { + self.app_product_info.push(ProductInfo { + name: product_name, + version: product_version, + }); + } + + pub(crate) fn add_stack_product_info(&mut self, product_name: String, product_version: String) { + self.stack_product_info.push(ProductInfo { name: product_name, version: product_version, }); diff --git a/src/query.rs b/src/query.rs index bdaf9520..9f03f56f 100644 --- a/src/query.rs +++ b/src/query.rs @@ -297,7 +297,12 @@ impl Query { *headers_mut = headers; } - builder = with_request_headers(builder, &self.client.headers, &self.client.products_info); + builder = with_request_headers( + builder, + &self.client.headers, + &self.client.app_product_info, + &self.client.stack_product_info, + ); builder = with_authentication(builder, &self.client.authentication); let content_length = query.len(); @@ -354,7 +359,6 @@ impl Query { self } - // Used in `clickhouse-ext-arrow` to track Arrow adoption. /// Similar to [`Client::with_product_info()`], but for this query only. pub fn with_product_info( self, @@ -367,6 +371,21 @@ impl Query { } } + // NOTE: SemVer-exempt API. + // Used in `clickhouse-ext-arrow` to track Arrow adoption. + // A separate method is necessary to ensure this doesn't end up ahead of the end-user's + // product info in the user agent string. + #[doc(hidden)] + pub fn with_stack_product_info( + mut self, + product_name: impl Into, + product_version: impl Into, + ) -> Self { + self.client + .add_stack_product_info(product_name.into(), product_version.into()); + self + } + /// Specify server side parameter for query. /// /// In queries, you can reference params as {name: type} e.g. {val: Int32}. diff --git a/tests/it/arrow.rs b/tests/it/arrow.rs index 76d40d9c..8f6f2531 100644 --- a/tests/it/arrow.rs +++ b/tests/it/arrow.rs @@ -250,7 +250,10 @@ async fn query_empty_response() { #[tokio::test] async fn ext_arrow_adds_user_agent() { - let client = prepare_database!(); + let client = prepare_database!() + // Ensure the product info added by `clickhouse-ext-arrow` is at the right place; + // it shouldn't end up ahead of our end-user's product info here. + .with_product_info("clickhouse-ext-arrow-test", "0.0.0"); client.query("CREATE TABLE arrow_product_info_test(foo Int32, bar String) ENGINE = MergeTree ORDER BY foo") .execute() @@ -295,7 +298,7 @@ async fn ext_arrow_adds_user_agent() { .unwrap(); let expected_user_agent = format!( - "clickhouse-ext-arrow/{ARROW_EXT_VER} clickhouse-rs/{PKG_VER} (lv:rust/{RUST_VER}; os:{OS})" + "clickhouse-ext-arrow-test/0.0.0 clickhouse-ext-arrow/{ARROW_EXT_VER} clickhouse-rs/{PKG_VER} (lv:rust/{RUST_VER}; os:{OS})" ); assert_eq!(recorded_user_agents.len(), 1); From 33323657651064bf689e5fb658586d9bcfb1b830 Mon Sep 17 00:00:00 2001 From: Austin Bonander Date: Thu, 1 Oct 2026 12:03:44 -0700 Subject: [PATCH 2/4] chore: update CHANGELOG with PR # --- ext-arrow/CHANGELOG.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ext-arrow/CHANGELOG.md b/ext-arrow/CHANGELOG.md index 9e54fe70..677b2943 100644 --- a/ext-arrow/CHANGELOG.md +++ b/ext-arrow/CHANGELOG.md @@ -11,7 +11,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed * Changed how product info is added internally so it doesn't end up in the user agent string ahead of any user-added - product info. + product info. ([#484]) + +[#484]: https://github.com/ClickHouse/clickhouse-rs/pull/484 ## [0.1.0] - 2026-06-01 From 2cc54634ec7ec2f8e31b6730b1c8c5253657ec8e Mon Sep 17 00:00:00 2001 From: Austin Bonander Date: Thu, 1 Oct 2026 12:51:02 -0700 Subject: [PATCH 3/4] fix: client `Debug` test --- src/lib.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/lib.rs b/src/lib.rs index e8a8d32c..e5c921ab 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1091,12 +1091,13 @@ Client { headers: [ \"X-Trace-Id\", ], - products_info: [ + app_product_info: [ ProductInfo { name: \"MyApp\", version: \"0.0.1\", }, ], + stack_product_info: [], validation: false, .. }"; From c23afbdd08c8fe3f79c6ea20dbfe5125d4ff3316 Mon Sep 17 00:00:00 2001 From: Austin Bonander Date: Mon, 5 Oct 2026 07:17:07 -0700 Subject: [PATCH 4/4] chore: address review comments --- ext-arrow/clippy.toml | 12 +++++++++--- src/insert_formatted.rs | 8 ++++---- src/lib.rs | 14 ++++++++++++++ src/query.rs | 19 +++++++++++-------- 4 files changed, 38 insertions(+), 15 deletions(-) diff --git a/ext-arrow/clippy.toml b/ext-arrow/clippy.toml index 9ab4f7b9..5f0490a5 100644 --- a/ext-arrow/clippy.toml +++ b/ext-arrow/clippy.toml @@ -1,14 +1,20 @@ +disallowed-macros = [ + # https://github.com/tokio-rs/tracing/issues/2448 + "tracing::enabled" +] + +# https://github.com/ClickHouse/clickhouse-rs/issues/469 [[disallowed-methods]] path = "clickhouse::Client::with_product_info" reason = "will place product info at front of user agent string, ahead of the app's product info" -replacement = "clickhouse::Client::with_stack_product_info" +replacement = "with_stack_product_info" [[disallowed-methods]] path = "clickhouse::query::Query::with_product_info" reason = "will place product info at front of user agent string, ahead of the app's product info" -replacement = "clickhouse::query::Query::with_stack_product_info" +replacement = "with_stack_product_info" [[disallowed-methods]] path = "clickhouse::insert_formatted::InsertFormatted::with_product_info" reason = "will place product info at front of user agent string, ahead of the app's product info" -replacement = "clickhouse::insert_formatted::InsertFormatted::with_stack_product_info" +replacement = "with_stack_product_info" diff --git a/src/insert_formatted.rs b/src/insert_formatted.rs index 6933de96..1f7d2345 100644 --- a/src/insert_formatted.rs +++ b/src/insert_formatted.rs @@ -275,10 +275,10 @@ impl InsertFormatted { self } - // NOTE: SemVer-exempt API. - // Used in `clickhouse-ext-arrow` to track Arrow adoption. - // A separate method is necessary to ensure this doesn't end up ahead of the end-user's - // product info in the user agent string. + /// NOTE: not meant for general use; prefer `.with_product_info()`. + /// Used in `clickhouse-ext-arrow` to track Arrow adoption. + /// A separate method is necessary to ensure this doesn't end up ahead of the end-user's + /// product info in the user agent string. #[doc(hidden)] pub fn with_stack_product_info( mut self, diff --git a/src/lib.rs b/src/lib.rs index e5c921ab..718782b5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -448,6 +448,20 @@ impl Client { self } + /// NOTE: not meant for general use; prefer `.with_product_info()`. + /// Used in `clickhouse-ext-arrow` to track Arrow adoption. + /// A separate method is necessary to ensure this doesn't end up ahead of the end-user's + /// product info in the user agent string. + #[doc(hidden)] + pub fn with_stack_product_info( + mut self, + product_name: impl Into, + product_version: impl Into, + ) -> Self { + self.add_stack_product_info(product_name.into(), product_version.into()); + self + } + pub(crate) fn add_product_info(&mut self, product_name: String, product_version: String) { self.app_product_info.push(ProductInfo { name: product_name, diff --git a/src/query.rs b/src/query.rs index 9f03f56f..b6ef2c31 100644 --- a/src/query.rs +++ b/src/query.rs @@ -371,19 +371,22 @@ impl Query { } } - // NOTE: SemVer-exempt API. - // Used in `clickhouse-ext-arrow` to track Arrow adoption. - // A separate method is necessary to ensure this doesn't end up ahead of the end-user's - // product info in the user agent string. + /// NOTE: not meant for general use; prefer `.with_product_info()`. + /// Used in `clickhouse-ext-arrow` to track Arrow adoption. + /// A separate method is necessary to ensure this doesn't end up ahead of the end-user's + /// product info in the user agent string. #[doc(hidden)] pub fn with_stack_product_info( - mut self, + self, product_name: impl Into, product_version: impl Into, ) -> Self { - self.client - .add_stack_product_info(product_name.into(), product_version.into()); - self + Self { + client: self + .client + .with_stack_product_info(product_name, product_version), + ..self + } } /// Specify server side parameter for query.