From 6d1a575ff664d77e78fb7cd3911efa4ab102375e Mon Sep 17 00:00:00 2001 From: Wes Tarle Date: Tue, 16 Sep 2025 17:10:02 -0400 Subject: [PATCH 1/3] impl(gax-internal): HttpSpanInfo struct, new, and update_from_outcome --- src/gax-internal/src/lib.rs | 3 + src/gax-internal/src/observability.rs | 275 ++++++++++++++++++++++++++ 2 files changed, 278 insertions(+) create mode 100644 src/gax-internal/src/observability.rs diff --git a/src/gax-internal/src/lib.rs b/src/gax-internal/src/lib.rs index 979a5b703a..f320017df6 100644 --- a/src/gax-internal/src/lib.rs +++ b/src/gax-internal/src/lib.rs @@ -36,6 +36,9 @@ pub mod query_parameter; #[cfg(feature = "_internal-http-client")] pub mod http; +#[cfg(feature = "_internal-http-client")] +pub mod observability; + #[cfg(feature = "_internal-grpc-client")] pub mod grpc; diff --git a/src/gax-internal/src/observability.rs b/src/gax-internal/src/observability.rs new file mode 100644 index 0000000000..ee2ec786b1 --- /dev/null +++ b/src/gax-internal/src/observability.rs @@ -0,0 +1,275 @@ +// Copyright 2025 Google LLC +// +// Licensed 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 +// +// https://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. + +use crate::options::InstrumentationClientInfo; +use gax::options::RequestOptions; + +/// Holds information extracted from HTTP requests and responses, +/// formatted to align with OpenTelemetry semantic conventions for tracing. +/// This struct is used to populate attributes on tracing spans. +#[derive(Debug, Clone)] +#[allow(dead_code)] // TODO(#3239): Remove once used in http.rs +pub(crate) struct HttpSpanInfo { + // Attributes for OpenTelemetry SDK interop + otel_kind: String, // "Client" + otel_name: String, // "{METHOD} {url.template}" or "{METHOD}" + otel_status: String, // "Unset", "Ok", "Error" + + // OpenTelemetry Semantic Conventions + rpc_system: String, // "http" + http_request_method: String, + server_address: Option, // Host from URL + server_port: Option, // Port from URL + url_full: String, + url_scheme: Option, + url_template: Option, // From RequestOptions.path_template + url_domain: Option, // Host from generator + + http_response_status_code: Option, + error_type: Option, + http_request_resend_count: Option, + + // Custom GCP Attributes + gcp_client_service: Option, + gcp_client_version: Option, + gcp_client_repo: String, // "googleapis/google-cloud-rust" + gcp_client_artifact: Option, +} + +impl HttpSpanInfo { + // TODO(#3239): Remove once used in http.rs + #[allow(dead_code)] + pub(crate) fn from_request( + request: &reqwest::Request, + options: &RequestOptions, + instrumentation: Option<&InstrumentationClientInfo>, + prior_attempt_count: u32, + ) -> Self { + let url = request.url(); + let method = request.method(); + + let url_template = gax::options::internal::get_path_template(options); + let otel_name = url_template.map_or_else( + || method.to_string(), + |template| format!("{} {}", method, template), + ); + + let http_request_resend_count = if prior_attempt_count > 0 { + Some(prior_attempt_count as i64) + } else { + None + }; + + let (gcp_client_service, gcp_client_version, gcp_client_artifact, url_domain) = + instrumentation.map_or((None, None, None, None), |info| { + ( + Some(info.service_name.to_string()), + Some(info.client_version.to_string()), + Some(info.client_artifact.to_string()), + Some(info.default_host.to_string()), + ) + }); + + Self { + rpc_system: "http".to_string(), + otel_kind: "Client".to_string(), + otel_name, + otel_status: "Unset".to_string(), + http_request_method: method.to_string(), + server_address: url.host_str().map(String::from), + server_port: url.port_or_known_default().map(|p| p as i64), + url_full: url.to_string(), + url_scheme: Some(url.scheme().to_string()), + url_template: url_template.map(String::from), + url_domain, + http_response_status_code: None, + error_type: None, + http_request_resend_count, + gcp_client_service, + gcp_client_version, + gcp_client_repo: "googleapis/google-cloud-rust".to_string(), + gcp_client_artifact, + } + } + + /// Updates the span info based on the outcome of the HTTP request. + /// This method should be called after the request has completed. + // TODO(#3239): Remove once used in http.rs + #[allow(dead_code)] + pub(crate) fn update_from_response( + &mut self, + result: &Result, + ) { + match result { + Ok(response) => { + self.http_response_status_code = Some(response.status().as_u16() as i64); + if response.status().is_success() { + self.otel_status = "Ok".to_string(); + } else { + self.otel_status = "Error".to_string(); + self.error_type = Some(response.status().to_string()); + } + } + Err(err) => { + self.otel_status = "Error".to_string(); + if err.is_timeout() { + self.error_type = Some("TIMEOUT".to_string()); + } else if err.is_connect() { + self.error_type = Some("CONNECTION_ERROR".to_string()); + } else if err.is_request() { + self.error_type = Some("REQUEST_ERROR".to_string()); + } else { + self.error_type = Some("UNKNOWN".to_string()); + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::options::InstrumentationClientInfo; + use gax::options::RequestOptions; + use http::Method; + use reqwest; + + #[tokio::test] + async fn test_http_span_info_from_request_basic() { + let request = + reqwest::Request::new(Method::GET, "https://example.com/test".parse().unwrap()); + let options = RequestOptions::default(); + + let span_info = HttpSpanInfo::from_request(&request, &options, None, 0); + + assert_eq!(span_info.rpc_system, "http"); + assert_eq!(span_info.otel_kind, "Client"); + assert_eq!(span_info.otel_name, "GET"); + assert_eq!(span_info.otel_status, "Unset"); + assert_eq!(span_info.http_request_method, "GET"); + assert_eq!(span_info.server_address, Some("example.com".to_string())); + assert_eq!(span_info.server_port, Some(443)); + assert_eq!(span_info.url_full, "https://example.com/test"); + assert_eq!(span_info.url_scheme, Some("https".to_string())); + assert_eq!(span_info.url_template, None); + assert_eq!(span_info.url_domain, None); + assert_eq!(span_info.http_response_status_code, None); + assert_eq!(span_info.error_type, None); + assert_eq!(span_info.http_request_resend_count, None); + assert_eq!(span_info.gcp_client_service, None); + assert_eq!(span_info.gcp_client_version, None); + assert_eq!(span_info.gcp_client_repo, "googleapis/google-cloud-rust"); + assert_eq!(span_info.gcp_client_artifact, None); + } + + #[tokio::test] + async fn test_http_span_info_from_request_with_instrumentation() { + let request = reqwest::Request::new( + Method::POST, + "https://test.service.dev:443/v1/items".parse().unwrap(), + ); + let options = RequestOptions::default(); + const INFO: InstrumentationClientInfo = InstrumentationClientInfo { + service_name: "test.service", + client_version: "1.2.3", + client_artifact: "google-cloud-test", + default_host: "test.service.dev", + }; + + let span_info = HttpSpanInfo::from_request(&request, &options, Some(&INFO), 0); + + assert_eq!( + span_info.gcp_client_service, + Some("test.service".to_string()) + ); + assert_eq!(span_info.gcp_client_version, Some("1.2.3".to_string())); + assert_eq!( + span_info.gcp_client_artifact, + Some("google-cloud-test".to_string()) + ); + assert_eq!(span_info.url_domain, Some("test.service.dev".to_string())); + assert_eq!( + span_info.server_address, + Some("test.service.dev".to_string()) + ); + assert_eq!(span_info.server_port, Some(443)); + } + + #[tokio::test] + async fn test_http_span_info_from_request_with_path_template() { + let request = reqwest::Request::new( + Method::GET, + "https://example.com/items/123".parse().unwrap(), + ); + let options = gax::options::internal::set_path_template( + RequestOptions::default(), + Some("/items/{item_id}".to_string()), + ); + + let span_info = HttpSpanInfo::from_request(&request, &options, None, 0); + + assert_eq!(span_info.url_template, Some("/items/{item_id}".to_string())); + assert_eq!(span_info.otel_name, "GET /items/{item_id}"); + } + + #[tokio::test] + async fn test_http_span_info_from_request_with_prior_attempt_count() { + let request = + reqwest::Request::new(Method::GET, "https://example.com/test".parse().unwrap()); + let options = RequestOptions::default(); + + // prior_attempt_count is 0 for the first try + let span_info = HttpSpanInfo::from_request(&request, &options, None, 0); + assert_eq!(span_info.http_request_resend_count, None); + + // prior_attempt_count is 1 for the second try (first retry) + let span_info = HttpSpanInfo::from_request(&request, &options, None, 1); + assert_eq!(span_info.http_request_resend_count, Some(1)); + + let span_info = HttpSpanInfo::from_request(&request, &options, None, 5); + assert_eq!(span_info.http_request_resend_count, Some(5)); + } + + #[tokio::test] + async fn test_update_from_response_success() { + let request = + reqwest::Request::new(Method::GET, "https://example.com/test".parse().unwrap()); + let mut span_info = + HttpSpanInfo::from_request(&request, &RequestOptions::default(), None, 0); + + let response = + reqwest::Response::from(http::Response::builder().status(200).body("").unwrap()); + span_info.update_from_response(&Ok(response)); + + assert_eq!(span_info.otel_status, "Ok"); + assert_eq!(span_info.http_response_status_code, Some(200)); + assert_eq!(span_info.error_type, None); + } + + #[tokio::test] + async fn test_update_from_response_http_error() { + let request = + reqwest::Request::new(Method::GET, "https://example.com/test".parse().unwrap()); + let mut span_info = + HttpSpanInfo::from_request(&request, &RequestOptions::default(), None, 0); + + let response = + reqwest::Response::from(http::Response::builder().status(404).body("").unwrap()); + span_info.update_from_response(&Ok(response)); + + assert_eq!(span_info.otel_status, "Error"); + assert_eq!(span_info.http_response_status_code, Some(404)); + assert_eq!(span_info.error_type, Some("404 Not Found".to_string())); + } +} From ca5a88128e41378f98353bb86695a3bdc2c718d1 Mon Sep 17 00:00:00 2001 From: Wes Tarle Date: Thu, 18 Sep 2025 10:18:27 -0400 Subject: [PATCH 2/3] impl(gax-internal): Address feedback on HttpSpanInfo --- src/gax-internal/src/observability.rs | 105 +++++++++++++++++--------- 1 file changed, 71 insertions(+), 34 deletions(-) diff --git a/src/gax-internal/src/observability.rs b/src/gax-internal/src/observability.rs index ee2ec786b1..8d9ba7b987 100644 --- a/src/gax-internal/src/observability.rs +++ b/src/gax-internal/src/observability.rs @@ -12,44 +12,84 @@ // See the License for the specific language governing permissions and // limitations under the License. +#![allow(dead_code)] // TODO(#3239): Remove once used in http.rs + use crate::options::InstrumentationClientInfo; use gax::options::RequestOptions; -/// Holds information extracted from HTTP requests and responses, -/// formatted to align with OpenTelemetry semantic conventions for tracing. -/// This struct is used to populate attributes on tracing spans. +#[derive(Debug, Clone, PartialEq)] +pub(crate) enum OtelStatus { + Unset, + Ok, + Error, +} + +impl OtelStatus { + pub(crate) fn as_str(&self) -> &'static str { + match self { + OtelStatus::Unset => "Unset", + OtelStatus::Ok => "Ok", + OtelStatus::Error => "Error", + } + } +} + +/// Populate attributes of tracing spans for HTTP requests. +/// +/// OpenTelemetry recommends a number of semantic conventions for +/// tracing HTTP requests. This type holds the information extracted +/// from HTTP requests and responses, formatted to align with these +/// OpenTelemetry semantic conventions. +/// See [OpenTelemetry Semantic Conventions for HTTP](https://opentelemetry.io/docs/specs/semconv/http/http-spans/). #[derive(Debug, Clone)] -#[allow(dead_code)] // TODO(#3239): Remove once used in http.rs pub(crate) struct HttpSpanInfo { // Attributes for OpenTelemetry SDK interop - otel_kind: String, // "Client" - otel_name: String, // "{METHOD} {url.template}" or "{METHOD}" - otel_status: String, // "Unset", "Ok", "Error" + /// Span Kind. Always CLIENT for a span representing an outbound HTTP request. + otel_kind: String, + /// Span Name. Recommended to be "{http.request.method} {url.template}" if url.template is available, otherwise "{http.request.method}". + otel_name: String, + /// Span Status. Set to Error in the event of an unrecoverable error, for example network error, 5xx status codes. Unset otherwise (including 4xx codes for CLIENT spans). + otel_status: OtelStatus, // OpenTelemetry Semantic Conventions - rpc_system: String, // "http" + /// Which RPC system is being used. Set to "http" for REST calls. + rpc_system: String, + /// The HTTP request method, for example GET; POST; HEAD. http_request_method: String, - server_address: Option, // Host from URL - server_port: Option, // Port from URL + /// The actual destination host name or IP address. May differ from the URL's host if overridden, for example myservice.googleapis.com; myservice-staging.sandbox.googleapis.com; 10.0.0.1. + server_address: String, + /// The actual destination port number. May differ from the URL's port if overridden, for example 443; 8080. + server_port: i64, + /// The absolute URL of the request, for example https://www.foo.bar/search?q=OpenTelemetry. url_full: String, + /// The URI scheme component identifying the used protocol, for example http; https. url_scheme: Option, - url_template: Option, // From RequestOptions.path_template - url_domain: Option, // Host from generator + /// The low-cardinality template of the absolute path, for example /v2/locations/{location}/projects/{project}/. + url_template: Option, + /// The nominal domain from the original URL, representing the intended service, for example myservice.googleapis.com. + url_domain: Option, + /// The numeric HTTP response status code, for example 200; 404; 500. http_response_status_code: Option, + /// A low cardinality a class of error the operation ended with. + /// For HTTP status codes >= 400, this is the status code as a string. + /// For network errors, a short identifier like TIMEOUT, CONNECTION_ERROR. error_type: Option, + /// The ordinal number of times this request has been resent (e.g., due to retries or redirects). None for the first attempt. http_request_resend_count: Option, // Custom GCP Attributes + /// Identifies the Google Cloud service, for example appengine; run; firestore. gcp_client_service: Option, + /// The version of the client library, for example v1.0.2. gcp_client_version: Option, - gcp_client_repo: String, // "googleapis/google-cloud-rust" + /// The repository of the client library. Always "googleapis/google-cloud-rust". + gcp_client_repo: String, + /// The crate name of the client library, for example google-cloud-storage. gcp_client_artifact: Option, } impl HttpSpanInfo { - // TODO(#3239): Remove once used in http.rs - #[allow(dead_code)] pub(crate) fn from_request( request: &reqwest::Request, options: &RequestOptions, @@ -85,10 +125,10 @@ impl HttpSpanInfo { rpc_system: "http".to_string(), otel_kind: "Client".to_string(), otel_name, - otel_status: "Unset".to_string(), + otel_status: OtelStatus::Unset, http_request_method: method.to_string(), - server_address: url.host_str().map(String::from), - server_port: url.port_or_known_default().map(|p| p as i64), + server_address: url.host_str().map(String::from).unwrap_or_default(), + server_port: url.port_or_known_default().map(|p| p as i64).unwrap_or(0), url_full: url.to_string(), url_scheme: Some(url.scheme().to_string()), url_template: url_template.map(String::from), @@ -104,9 +144,9 @@ impl HttpSpanInfo { } /// Updates the span info based on the outcome of the HTTP request. - /// This method should be called after the request has completed. - // TODO(#3239): Remove once used in http.rs - #[allow(dead_code)] + /// + /// This method should be called after the request has completed, it will fill in any parts of + /// the span that depend on the result of the request. pub(crate) fn update_from_response( &mut self, result: &Result, @@ -115,14 +155,14 @@ impl HttpSpanInfo { Ok(response) => { self.http_response_status_code = Some(response.status().as_u16() as i64); if response.status().is_success() { - self.otel_status = "Ok".to_string(); + self.otel_status = OtelStatus::Ok; } else { - self.otel_status = "Error".to_string(); + self.otel_status = OtelStatus::Error; self.error_type = Some(response.status().to_string()); } } Err(err) => { - self.otel_status = "Error".to_string(); + self.otel_status = OtelStatus::Error; if err.is_timeout() { self.error_type = Some("TIMEOUT".to_string()); } else if err.is_connect() { @@ -156,10 +196,10 @@ mod tests { assert_eq!(span_info.rpc_system, "http"); assert_eq!(span_info.otel_kind, "Client"); assert_eq!(span_info.otel_name, "GET"); - assert_eq!(span_info.otel_status, "Unset"); + assert_eq!(span_info.otel_status, OtelStatus::Unset); assert_eq!(span_info.http_request_method, "GET"); - assert_eq!(span_info.server_address, Some("example.com".to_string())); - assert_eq!(span_info.server_port, Some(443)); + assert_eq!(span_info.server_address, "example.com".to_string()); + assert_eq!(span_info.server_port, 443); assert_eq!(span_info.url_full, "https://example.com/test"); assert_eq!(span_info.url_scheme, Some("https".to_string())); assert_eq!(span_info.url_template, None); @@ -199,11 +239,8 @@ mod tests { Some("google-cloud-test".to_string()) ); assert_eq!(span_info.url_domain, Some("test.service.dev".to_string())); - assert_eq!( - span_info.server_address, - Some("test.service.dev".to_string()) - ); - assert_eq!(span_info.server_port, Some(443)); + assert_eq!(span_info.server_address, "test.service.dev".to_string()); + assert_eq!(span_info.server_port, 443); } #[tokio::test] @@ -252,7 +289,7 @@ mod tests { reqwest::Response::from(http::Response::builder().status(200).body("").unwrap()); span_info.update_from_response(&Ok(response)); - assert_eq!(span_info.otel_status, "Ok"); + assert_eq!(span_info.otel_status, OtelStatus::Ok); assert_eq!(span_info.http_response_status_code, Some(200)); assert_eq!(span_info.error_type, None); } @@ -268,7 +305,7 @@ mod tests { reqwest::Response::from(http::Response::builder().status(404).body("").unwrap()); span_info.update_from_response(&Ok(response)); - assert_eq!(span_info.otel_status, "Error"); + assert_eq!(span_info.otel_status, OtelStatus::Error); assert_eq!(span_info.http_response_status_code, Some(404)); assert_eq!(span_info.error_type, Some("404 Not Found".to_string())); } From 23cd64390cd868ac743eb4ca2ece9fa95155f193 Mon Sep 17 00:00:00 2001 From: Wes Tarle Date: Thu, 18 Sep 2025 11:39:41 -0400 Subject: [PATCH 3/3] test(gax-internal): Add test for OtelStatus::as_str --- src/gax-internal/src/observability.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/gax-internal/src/observability.rs b/src/gax-internal/src/observability.rs index 8d9ba7b987..1f49a85099 100644 --- a/src/gax-internal/src/observability.rs +++ b/src/gax-internal/src/observability.rs @@ -309,4 +309,11 @@ mod tests { assert_eq!(span_info.http_response_status_code, Some(404)); assert_eq!(span_info.error_type, Some("404 Not Found".to_string())); } + + #[test] + fn test_otel_status_as_str() { + assert_eq!(OtelStatus::Unset.as_str(), "Unset"); + assert_eq!(OtelStatus::Ok.as_str(), "Ok"); + assert_eq!(OtelStatus::Error.as_str(), "Error"); + } }