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
+ use crate :: options:: InstrumentationClientInfo ;
16
+ use gax:: options:: RequestOptions ;
17
+
18
+ /// Holds information extracted from HTTP requests and responses,
19
+ /// formatted to align with OpenTelemetry semantic conventions for tracing.
20
+ /// This struct is used to populate attributes on tracing spans.
21
+ #[ derive( Debug , Clone ) ]
22
+ #[ allow( dead_code) ] // TODO(#3239): Remove once used in http.rs
23
+ pub ( crate ) struct HttpSpanInfo {
24
+ // Attributes for OpenTelemetry SDK interop
25
+ otel_kind : String , // "Client"
26
+ otel_name : String , // "{METHOD} {url.template}" or "{METHOD}"
27
+ otel_status : String , // "Unset", "Ok", "Error"
28
+
29
+ // OpenTelemetry Semantic Conventions
30
+ rpc_system : String , // "http"
31
+ http_request_method : String ,
32
+ server_address : Option < String > , // Host from URL
33
+ server_port : Option < i64 > , // Port from URL
34
+ url_full : String ,
35
+ url_scheme : Option < String > ,
36
+ url_template : Option < String > , // From RequestOptions.path_template
37
+ url_domain : Option < String > , // Host from generator
38
+
39
+ http_response_status_code : Option < i64 > ,
40
+ error_type : Option < String > ,
41
+ http_request_resend_count : Option < i64 > ,
42
+
43
+ // Custom GCP Attributes
44
+ gcp_client_service : Option < String > ,
45
+ gcp_client_version : Option < String > ,
46
+ gcp_client_repo : String , // "googleapis/google-cloud-rust"
47
+ gcp_client_artifact : Option < String > ,
48
+ }
49
+
50
+ impl HttpSpanInfo {
51
+ // TODO(#3239): Remove once used in http.rs
52
+ #[ allow( dead_code) ]
53
+ pub ( crate ) fn from_request (
54
+ request : & reqwest:: Request ,
55
+ options : & RequestOptions ,
56
+ instrumentation : Option < & InstrumentationClientInfo > ,
57
+ prior_attempt_count : u32 ,
58
+ ) -> Self {
59
+ let url = request. url ( ) ;
60
+ let method = request. method ( ) ;
61
+
62
+ let url_template = gax:: options:: internal:: get_path_template ( options) ;
63
+ let otel_name = url_template. map_or_else (
64
+ || method. to_string ( ) ,
65
+ |template| format ! ( "{} {}" , method, template) ,
66
+ ) ;
67
+
68
+ let http_request_resend_count = if prior_attempt_count > 0 {
69
+ Some ( prior_attempt_count as i64 )
70
+ } else {
71
+ None
72
+ } ;
73
+
74
+ let ( gcp_client_service, gcp_client_version, gcp_client_artifact, url_domain) =
75
+ instrumentation. map_or ( ( None , None , None , None ) , |info| {
76
+ (
77
+ Some ( info. service_name . to_string ( ) ) ,
78
+ Some ( info. client_version . to_string ( ) ) ,
79
+ Some ( info. client_artifact . to_string ( ) ) ,
80
+ Some ( info. default_host . to_string ( ) ) ,
81
+ )
82
+ } ) ;
83
+
84
+ Self {
85
+ rpc_system : "http" . to_string ( ) ,
86
+ otel_kind : "Client" . to_string ( ) ,
87
+ otel_name,
88
+ otel_status : "Unset" . to_string ( ) ,
89
+ http_request_method : method. to_string ( ) ,
90
+ server_address : url. host_str ( ) . map ( String :: from) ,
91
+ server_port : url. port_or_known_default ( ) . map ( |p| p as i64 ) ,
92
+ url_full : url. to_string ( ) ,
93
+ url_scheme : Some ( url. scheme ( ) . to_string ( ) ) ,
94
+ url_template : url_template. map ( String :: from) ,
95
+ url_domain,
96
+ http_response_status_code : None ,
97
+ error_type : None ,
98
+ http_request_resend_count,
99
+ gcp_client_service,
100
+ gcp_client_version,
101
+ gcp_client_repo : "googleapis/google-cloud-rust" . to_string ( ) ,
102
+ gcp_client_artifact,
103
+ }
104
+ }
105
+
106
+ /// Updates the span info based on the outcome of the HTTP request.
107
+ /// This method should be called after the request has completed.
108
+ // TODO(#3239): Remove once used in http.rs
109
+ #[ allow( dead_code) ]
110
+ pub ( crate ) fn update_from_response (
111
+ & mut self ,
112
+ result : & Result < reqwest:: Response , reqwest:: Error > ,
113
+ ) {
114
+ match result {
115
+ Ok ( response) => {
116
+ self . http_response_status_code = Some ( response. status ( ) . as_u16 ( ) as i64 ) ;
117
+ if response. status ( ) . is_success ( ) {
118
+ self . otel_status = "Ok" . to_string ( ) ;
119
+ } else {
120
+ self . otel_status = "Error" . to_string ( ) ;
121
+ self . error_type = Some ( response. status ( ) . to_string ( ) ) ;
122
+ }
123
+ }
124
+ Err ( err) => {
125
+ self . otel_status = "Error" . to_string ( ) ;
126
+ if err. is_timeout ( ) {
127
+ self . error_type = Some ( "TIMEOUT" . to_string ( ) ) ;
128
+ } else if err. is_connect ( ) {
129
+ self . error_type = Some ( "CONNECTION_ERROR" . to_string ( ) ) ;
130
+ } else if err. is_request ( ) {
131
+ self . error_type = Some ( "REQUEST_ERROR" . to_string ( ) ) ;
132
+ } else {
133
+ self . error_type = Some ( "UNKNOWN" . to_string ( ) ) ;
134
+ }
135
+ }
136
+ }
137
+ }
138
+ }
139
+
140
+ #[ cfg( test) ]
141
+ mod tests {
142
+ use super :: * ;
143
+ use crate :: options:: InstrumentationClientInfo ;
144
+ use gax:: options:: RequestOptions ;
145
+ use http:: Method ;
146
+ use reqwest;
147
+
148
+ #[ tokio:: test]
149
+ async fn test_http_span_info_from_request_basic ( ) {
150
+ let request =
151
+ reqwest:: Request :: new ( Method :: GET , "https://example.com/test" . parse ( ) . unwrap ( ) ) ;
152
+ let options = RequestOptions :: default ( ) ;
153
+
154
+ let span_info = HttpSpanInfo :: from_request ( & request, & options, None , 0 ) ;
155
+
156
+ assert_eq ! ( span_info. rpc_system, "http" ) ;
157
+ assert_eq ! ( span_info. otel_kind, "Client" ) ;
158
+ assert_eq ! ( span_info. otel_name, "GET" ) ;
159
+ assert_eq ! ( span_info. otel_status, "Unset" ) ;
160
+ assert_eq ! ( span_info. http_request_method, "GET" ) ;
161
+ assert_eq ! ( span_info. server_address, Some ( "example.com" . to_string( ) ) ) ;
162
+ assert_eq ! ( span_info. server_port, Some ( 443 ) ) ;
163
+ assert_eq ! ( span_info. url_full, "https://example.com/test" ) ;
164
+ assert_eq ! ( span_info. url_scheme, Some ( "https" . to_string( ) ) ) ;
165
+ assert_eq ! ( span_info. url_template, None ) ;
166
+ assert_eq ! ( span_info. url_domain, None ) ;
167
+ assert_eq ! ( span_info. http_response_status_code, None ) ;
168
+ assert_eq ! ( span_info. error_type, None ) ;
169
+ assert_eq ! ( span_info. http_request_resend_count, None ) ;
170
+ assert_eq ! ( span_info. gcp_client_service, None ) ;
171
+ assert_eq ! ( span_info. gcp_client_version, None ) ;
172
+ assert_eq ! ( span_info. gcp_client_repo, "googleapis/google-cloud-rust" ) ;
173
+ assert_eq ! ( span_info. gcp_client_artifact, None ) ;
174
+ }
175
+
176
+ #[ tokio:: test]
177
+ async fn test_http_span_info_from_request_with_instrumentation ( ) {
178
+ let request = reqwest:: Request :: new (
179
+ Method :: POST ,
180
+ "https://test.service.dev:443/v1/items" . parse ( ) . unwrap ( ) ,
181
+ ) ;
182
+ let options = RequestOptions :: default ( ) ;
183
+ const INFO : InstrumentationClientInfo = InstrumentationClientInfo {
184
+ service_name : "test.service" ,
185
+ client_version : "1.2.3" ,
186
+ client_artifact : "google-cloud-test" ,
187
+ default_host : "test.service.dev" ,
188
+ } ;
189
+
190
+ let span_info = HttpSpanInfo :: from_request ( & request, & options, Some ( & INFO ) , 0 ) ;
191
+
192
+ assert_eq ! (
193
+ span_info. gcp_client_service,
194
+ Some ( "test.service" . to_string( ) )
195
+ ) ;
196
+ assert_eq ! ( span_info. gcp_client_version, Some ( "1.2.3" . to_string( ) ) ) ;
197
+ assert_eq ! (
198
+ span_info. gcp_client_artifact,
199
+ Some ( "google-cloud-test" . to_string( ) )
200
+ ) ;
201
+ assert_eq ! ( span_info. url_domain, Some ( "test.service.dev" . to_string( ) ) ) ;
202
+ assert_eq ! (
203
+ span_info. server_address,
204
+ Some ( "test.service.dev" . to_string( ) )
205
+ ) ;
206
+ assert_eq ! ( span_info. server_port, Some ( 443 ) ) ;
207
+ }
208
+
209
+ #[ tokio:: test]
210
+ async fn test_http_span_info_from_request_with_path_template ( ) {
211
+ let request = reqwest:: Request :: new (
212
+ Method :: GET ,
213
+ "https://example.com/items/123" . parse ( ) . unwrap ( ) ,
214
+ ) ;
215
+ let options = gax:: options:: internal:: set_path_template (
216
+ RequestOptions :: default ( ) ,
217
+ Some ( "/items/{item_id}" . to_string ( ) ) ,
218
+ ) ;
219
+
220
+ let span_info = HttpSpanInfo :: from_request ( & request, & options, None , 0 ) ;
221
+
222
+ assert_eq ! ( span_info. url_template, Some ( "/items/{item_id}" . to_string( ) ) ) ;
223
+ assert_eq ! ( span_info. otel_name, "GET /items/{item_id}" ) ;
224
+ }
225
+
226
+ #[ tokio:: test]
227
+ async fn test_http_span_info_from_request_with_prior_attempt_count ( ) {
228
+ let request =
229
+ reqwest:: Request :: new ( Method :: GET , "https://example.com/test" . parse ( ) . unwrap ( ) ) ;
230
+ let options = RequestOptions :: default ( ) ;
231
+
232
+ // prior_attempt_count is 0 for the first try
233
+ let span_info = HttpSpanInfo :: from_request ( & request, & options, None , 0 ) ;
234
+ assert_eq ! ( span_info. http_request_resend_count, None ) ;
235
+
236
+ // prior_attempt_count is 1 for the second try (first retry)
237
+ let span_info = HttpSpanInfo :: from_request ( & request, & options, None , 1 ) ;
238
+ assert_eq ! ( span_info. http_request_resend_count, Some ( 1 ) ) ;
239
+
240
+ let span_info = HttpSpanInfo :: from_request ( & request, & options, None , 5 ) ;
241
+ assert_eq ! ( span_info. http_request_resend_count, Some ( 5 ) ) ;
242
+ }
243
+
244
+ #[ tokio:: test]
245
+ async fn test_update_from_response_success ( ) {
246
+ let request =
247
+ reqwest:: Request :: new ( Method :: GET , "https://example.com/test" . parse ( ) . unwrap ( ) ) ;
248
+ let mut span_info =
249
+ HttpSpanInfo :: from_request ( & request, & RequestOptions :: default ( ) , None , 0 ) ;
250
+
251
+ let response =
252
+ reqwest:: Response :: from ( http:: Response :: builder ( ) . status ( 200 ) . body ( "" ) . unwrap ( ) ) ;
253
+ span_info. update_from_response ( & Ok ( response) ) ;
254
+
255
+ assert_eq ! ( span_info. otel_status, "Ok" ) ;
256
+ assert_eq ! ( span_info. http_response_status_code, Some ( 200 ) ) ;
257
+ assert_eq ! ( span_info. error_type, None ) ;
258
+ }
259
+
260
+ #[ tokio:: test]
261
+ async fn test_update_from_response_http_error ( ) {
262
+ let request =
263
+ reqwest:: Request :: new ( Method :: GET , "https://example.com/test" . parse ( ) . unwrap ( ) ) ;
264
+ let mut span_info =
265
+ HttpSpanInfo :: from_request ( & request, & RequestOptions :: default ( ) , None , 0 ) ;
266
+
267
+ let response =
268
+ reqwest:: Response :: from ( http:: Response :: builder ( ) . status ( 404 ) . body ( "" ) . unwrap ( ) ) ;
269
+ span_info. update_from_response ( & Ok ( response) ) ;
270
+
271
+ assert_eq ! ( span_info. otel_status, "Error" ) ;
272
+ assert_eq ! ( span_info. http_response_status_code, Some ( 404 ) ) ;
273
+ assert_eq ! ( span_info. error_type, Some ( "404 Not Found" . to_string( ) ) ) ;
274
+ }
275
+ }
0 commit comments