Skip to content

Commit b128db3

Browse files
authored
impl(gax-internal): HttpSpanInfo struct, new, and update_from_outcome (#3352)
1 parent 0431b38 commit b128db3

File tree

2 files changed

+322
-0
lines changed

2 files changed

+322
-0
lines changed

src/gax-internal/src/lib.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@ pub mod query_parameter;
3636
#[cfg(feature = "_internal-http-client")]
3737
pub mod http;
3838

39+
#[cfg(feature = "_internal-http-client")]
40+
pub mod observability;
41+
3942
#[cfg(feature = "_internal-grpc-client")]
4043
pub mod grpc;
4144

Lines changed: 319 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,319 @@
1+
// Copyright 2025 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// https://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
#![allow(dead_code)] // TODO(#3239): Remove once used in http.rs
16+
17+
use crate::options::InstrumentationClientInfo;
18+
use gax::options::RequestOptions;
19+
20+
#[derive(Debug, Clone, PartialEq)]
21+
pub(crate) enum OtelStatus {
22+
Unset,
23+
Ok,
24+
Error,
25+
}
26+
27+
impl OtelStatus {
28+
pub(crate) fn as_str(&self) -> &'static str {
29+
match self {
30+
OtelStatus::Unset => "Unset",
31+
OtelStatus::Ok => "Ok",
32+
OtelStatus::Error => "Error",
33+
}
34+
}
35+
}
36+
37+
/// Populate attributes of tracing spans for HTTP requests.
38+
///
39+
/// OpenTelemetry recommends a number of semantic conventions for
40+
/// tracing HTTP requests. This type holds the information extracted
41+
/// from HTTP requests and responses, formatted to align with these
42+
/// OpenTelemetry semantic conventions.
43+
/// See [OpenTelemetry Semantic Conventions for HTTP](https://opentelemetry.io/docs/specs/semconv/http/http-spans/).
44+
#[derive(Debug, Clone)]
45+
pub(crate) struct HttpSpanInfo {
46+
// Attributes for OpenTelemetry SDK interop
47+
/// Span Kind. Always CLIENT for a span representing an outbound HTTP request.
48+
otel_kind: String,
49+
/// Span Name. Recommended to be "{http.request.method} {url.template}" if url.template is available, otherwise "{http.request.method}".
50+
otel_name: String,
51+
/// 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).
52+
otel_status: OtelStatus,
53+
54+
// OpenTelemetry Semantic Conventions
55+
/// Which RPC system is being used. Set to "http" for REST calls.
56+
rpc_system: String,
57+
/// The HTTP request method, for example GET; POST; HEAD.
58+
http_request_method: String,
59+
/// 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.
60+
server_address: String,
61+
/// The actual destination port number. May differ from the URL's port if overridden, for example 443; 8080.
62+
server_port: i64,
63+
/// The absolute URL of the request, for example https://www.foo.bar/search?q=OpenTelemetry.
64+
url_full: String,
65+
/// The URI scheme component identifying the used protocol, for example http; https.
66+
url_scheme: Option<String>,
67+
/// The low-cardinality template of the absolute path, for example /v2/locations/{location}/projects/{project}/.
68+
url_template: Option<String>,
69+
/// The nominal domain from the original URL, representing the intended service, for example myservice.googleapis.com.
70+
url_domain: Option<String>,
71+
72+
/// The numeric HTTP response status code, for example 200; 404; 500.
73+
http_response_status_code: Option<i64>,
74+
/// A low cardinality a class of error the operation ended with.
75+
/// For HTTP status codes >= 400, this is the status code as a string.
76+
/// For network errors, a short identifier like TIMEOUT, CONNECTION_ERROR.
77+
error_type: Option<String>,
78+
/// The ordinal number of times this request has been resent (e.g., due to retries or redirects). None for the first attempt.
79+
http_request_resend_count: Option<i64>,
80+
81+
// Custom GCP Attributes
82+
/// Identifies the Google Cloud service, for example appengine; run; firestore.
83+
gcp_client_service: Option<String>,
84+
/// The version of the client library, for example v1.0.2.
85+
gcp_client_version: Option<String>,
86+
/// The repository of the client library. Always "googleapis/google-cloud-rust".
87+
gcp_client_repo: String,
88+
/// The crate name of the client library, for example google-cloud-storage.
89+
gcp_client_artifact: Option<String>,
90+
}
91+
92+
impl HttpSpanInfo {
93+
pub(crate) fn from_request(
94+
request: &reqwest::Request,
95+
options: &RequestOptions,
96+
instrumentation: Option<&InstrumentationClientInfo>,
97+
prior_attempt_count: u32,
98+
) -> Self {
99+
let url = request.url();
100+
let method = request.method();
101+
102+
let url_template = gax::options::internal::get_path_template(options);
103+
let otel_name = url_template.map_or_else(
104+
|| method.to_string(),
105+
|template| format!("{} {}", method, template),
106+
);
107+
108+
let http_request_resend_count = if prior_attempt_count > 0 {
109+
Some(prior_attempt_count as i64)
110+
} else {
111+
None
112+
};
113+
114+
let (gcp_client_service, gcp_client_version, gcp_client_artifact, url_domain) =
115+
instrumentation.map_or((None, None, None, None), |info| {
116+
(
117+
Some(info.service_name.to_string()),
118+
Some(info.client_version.to_string()),
119+
Some(info.client_artifact.to_string()),
120+
Some(info.default_host.to_string()),
121+
)
122+
});
123+
124+
Self {
125+
rpc_system: "http".to_string(),
126+
otel_kind: "Client".to_string(),
127+
otel_name,
128+
otel_status: OtelStatus::Unset,
129+
http_request_method: method.to_string(),
130+
server_address: url.host_str().map(String::from).unwrap_or_default(),
131+
server_port: url.port_or_known_default().map(|p| p as i64).unwrap_or(0),
132+
url_full: url.to_string(),
133+
url_scheme: Some(url.scheme().to_string()),
134+
url_template: url_template.map(String::from),
135+
url_domain,
136+
http_response_status_code: None,
137+
error_type: None,
138+
http_request_resend_count,
139+
gcp_client_service,
140+
gcp_client_version,
141+
gcp_client_repo: "googleapis/google-cloud-rust".to_string(),
142+
gcp_client_artifact,
143+
}
144+
}
145+
146+
/// Updates the span info based on the outcome of the HTTP request.
147+
///
148+
/// This method should be called after the request has completed, it will fill in any parts of
149+
/// the span that depend on the result of the request.
150+
pub(crate) fn update_from_response(
151+
&mut self,
152+
result: &Result<reqwest::Response, reqwest::Error>,
153+
) {
154+
match result {
155+
Ok(response) => {
156+
self.http_response_status_code = Some(response.status().as_u16() as i64);
157+
if response.status().is_success() {
158+
self.otel_status = OtelStatus::Ok;
159+
} else {
160+
self.otel_status = OtelStatus::Error;
161+
self.error_type = Some(response.status().to_string());
162+
}
163+
}
164+
Err(err) => {
165+
self.otel_status = OtelStatus::Error;
166+
if err.is_timeout() {
167+
self.error_type = Some("TIMEOUT".to_string());
168+
} else if err.is_connect() {
169+
self.error_type = Some("CONNECTION_ERROR".to_string());
170+
} else if err.is_request() {
171+
self.error_type = Some("REQUEST_ERROR".to_string());
172+
} else {
173+
self.error_type = Some("UNKNOWN".to_string());
174+
}
175+
}
176+
}
177+
}
178+
}
179+
180+
#[cfg(test)]
181+
mod tests {
182+
use super::*;
183+
use crate::options::InstrumentationClientInfo;
184+
use gax::options::RequestOptions;
185+
use http::Method;
186+
use reqwest;
187+
188+
#[tokio::test]
189+
async fn test_http_span_info_from_request_basic() {
190+
let request =
191+
reqwest::Request::new(Method::GET, "https://example.com/test".parse().unwrap());
192+
let options = RequestOptions::default();
193+
194+
let span_info = HttpSpanInfo::from_request(&request, &options, None, 0);
195+
196+
assert_eq!(span_info.rpc_system, "http");
197+
assert_eq!(span_info.otel_kind, "Client");
198+
assert_eq!(span_info.otel_name, "GET");
199+
assert_eq!(span_info.otel_status, OtelStatus::Unset);
200+
assert_eq!(span_info.http_request_method, "GET");
201+
assert_eq!(span_info.server_address, "example.com".to_string());
202+
assert_eq!(span_info.server_port, 443);
203+
assert_eq!(span_info.url_full, "https://example.com/test");
204+
assert_eq!(span_info.url_scheme, Some("https".to_string()));
205+
assert_eq!(span_info.url_template, None);
206+
assert_eq!(span_info.url_domain, None);
207+
assert_eq!(span_info.http_response_status_code, None);
208+
assert_eq!(span_info.error_type, None);
209+
assert_eq!(span_info.http_request_resend_count, None);
210+
assert_eq!(span_info.gcp_client_service, None);
211+
assert_eq!(span_info.gcp_client_version, None);
212+
assert_eq!(span_info.gcp_client_repo, "googleapis/google-cloud-rust");
213+
assert_eq!(span_info.gcp_client_artifact, None);
214+
}
215+
216+
#[tokio::test]
217+
async fn test_http_span_info_from_request_with_instrumentation() {
218+
let request = reqwest::Request::new(
219+
Method::POST,
220+
"https://test.service.dev:443/v1/items".parse().unwrap(),
221+
);
222+
let options = RequestOptions::default();
223+
const INFO: InstrumentationClientInfo = InstrumentationClientInfo {
224+
service_name: "test.service",
225+
client_version: "1.2.3",
226+
client_artifact: "google-cloud-test",
227+
default_host: "test.service.dev",
228+
};
229+
230+
let span_info = HttpSpanInfo::from_request(&request, &options, Some(&INFO), 0);
231+
232+
assert_eq!(
233+
span_info.gcp_client_service,
234+
Some("test.service".to_string())
235+
);
236+
assert_eq!(span_info.gcp_client_version, Some("1.2.3".to_string()));
237+
assert_eq!(
238+
span_info.gcp_client_artifact,
239+
Some("google-cloud-test".to_string())
240+
);
241+
assert_eq!(span_info.url_domain, Some("test.service.dev".to_string()));
242+
assert_eq!(span_info.server_address, "test.service.dev".to_string());
243+
assert_eq!(span_info.server_port, 443);
244+
}
245+
246+
#[tokio::test]
247+
async fn test_http_span_info_from_request_with_path_template() {
248+
let request = reqwest::Request::new(
249+
Method::GET,
250+
"https://example.com/items/123".parse().unwrap(),
251+
);
252+
let options = gax::options::internal::set_path_template(
253+
RequestOptions::default(),
254+
Some("/items/{item_id}".to_string()),
255+
);
256+
257+
let span_info = HttpSpanInfo::from_request(&request, &options, None, 0);
258+
259+
assert_eq!(span_info.url_template, Some("/items/{item_id}".to_string()));
260+
assert_eq!(span_info.otel_name, "GET /items/{item_id}");
261+
}
262+
263+
#[tokio::test]
264+
async fn test_http_span_info_from_request_with_prior_attempt_count() {
265+
let request =
266+
reqwest::Request::new(Method::GET, "https://example.com/test".parse().unwrap());
267+
let options = RequestOptions::default();
268+
269+
// prior_attempt_count is 0 for the first try
270+
let span_info = HttpSpanInfo::from_request(&request, &options, None, 0);
271+
assert_eq!(span_info.http_request_resend_count, None);
272+
273+
// prior_attempt_count is 1 for the second try (first retry)
274+
let span_info = HttpSpanInfo::from_request(&request, &options, None, 1);
275+
assert_eq!(span_info.http_request_resend_count, Some(1));
276+
277+
let span_info = HttpSpanInfo::from_request(&request, &options, None, 5);
278+
assert_eq!(span_info.http_request_resend_count, Some(5));
279+
}
280+
281+
#[tokio::test]
282+
async fn test_update_from_response_success() {
283+
let request =
284+
reqwest::Request::new(Method::GET, "https://example.com/test".parse().unwrap());
285+
let mut span_info =
286+
HttpSpanInfo::from_request(&request, &RequestOptions::default(), None, 0);
287+
288+
let response =
289+
reqwest::Response::from(http::Response::builder().status(200).body("").unwrap());
290+
span_info.update_from_response(&Ok(response));
291+
292+
assert_eq!(span_info.otel_status, OtelStatus::Ok);
293+
assert_eq!(span_info.http_response_status_code, Some(200));
294+
assert_eq!(span_info.error_type, None);
295+
}
296+
297+
#[tokio::test]
298+
async fn test_update_from_response_http_error() {
299+
let request =
300+
reqwest::Request::new(Method::GET, "https://example.com/test".parse().unwrap());
301+
let mut span_info =
302+
HttpSpanInfo::from_request(&request, &RequestOptions::default(), None, 0);
303+
304+
let response =
305+
reqwest::Response::from(http::Response::builder().status(404).body("").unwrap());
306+
span_info.update_from_response(&Ok(response));
307+
308+
assert_eq!(span_info.otel_status, OtelStatus::Error);
309+
assert_eq!(span_info.http_response_status_code, Some(404));
310+
assert_eq!(span_info.error_type, Some("404 Not Found".to_string()));
311+
}
312+
313+
#[test]
314+
fn test_otel_status_as_str() {
315+
assert_eq!(OtelStatus::Unset.as_str(), "Unset");
316+
assert_eq!(OtelStatus::Ok.as_str(), "Ok");
317+
assert_eq!(OtelStatus::Error.as_str(), "Error");
318+
}
319+
}

0 commit comments

Comments
 (0)