Skip to main content

telemetry_rust/instrumentations/http/
reqwest.rs

1//! Async reqwest instrumentation helpers.
2//!
3//! # Example
4//!
5//! ```no_run
6//! use telemetry_rust::instrumentations::http::reqwest::ReqwestBuilderInstrument;
7//!
8//! # async fn example() -> Result<(), reqwest::Error> {
9//! let response = reqwest::Client::new()
10//!     .get("https://example.com/health")
11//!     .instrument()
12//!     .send()
13//!     .await?;
14//! # let _ = response;
15//! # Ok(())
16//! # }
17//! ```
18
19use ::reqwest as reqwest_crate;
20use std::future::Future;
21
22use crate::{
23    Context, Value, http,
24    instrumentations::http::client::{HttpClientSpanBuilder, UrlParts},
25};
26
27impl UrlParts for url::Url {
28    fn full_url(&self) -> Option<impl Into<Value>> {
29        Some(self.as_str().to_owned())
30    }
31
32    fn path(&self) -> Option<impl Into<Value>> {
33        Some(self.path().to_owned())
34    }
35
36    fn host(&self) -> Option<impl Into<Value>> {
37        self.host_str().map(ToOwned::to_owned)
38    }
39
40    fn scheme(&self) -> Option<impl Into<Value>> {
41        match self.scheme() {
42            "" => None,
43            scheme => Some(scheme.to_owned()),
44        }
45    }
46
47    fn port(&self) -> Option<impl Into<Value>> {
48        self.port_or_known_default().map(i64::from)
49    }
50
51    fn query(&self) -> Option<impl Into<Value>> {
52        self.query().map(ToOwned::to_owned)
53    }
54}
55
56/// A trait for instrumenting async reqwest request builders with OpenTelemetry tracing.
57///
58/// ```no_run
59/// use telemetry_rust::instrumentations::http::reqwest::ReqwestBuilderInstrument;
60///
61/// # async fn example() -> Result<(), reqwest::Error> {
62/// let response = reqwest::Client::new()
63///     .get("https://example.com/health")
64///     .instrument()
65///     .send()
66///     .await?;
67/// # let _ = response;
68/// # Ok(())
69/// # }
70/// ```
71pub trait ReqwestBuilderInstrument
72where
73    Self: Sized,
74{
75    /// Instruments this reqwest builder with OpenTelemetry tracing.
76    fn instrument(self) -> InstrumentedRequestBuilder;
77}
78
79impl ReqwestBuilderInstrument for reqwest_crate::RequestBuilder {
80    fn instrument(self) -> InstrumentedRequestBuilder {
81        InstrumentedRequestBuilder::new(self)
82    }
83}
84
85impl HttpClientSpanBuilder {
86    pub(crate) fn from_reqwest_request(request: &reqwest_crate::Request) -> Self {
87        Self::from_parts(request.method(), request.headers(), request.url())
88    }
89}
90
91/// A wrapper that instruments async reqwest request builders with OpenTelemetry tracing.
92#[must_use = "RequestBuilder does nothing until you call send()"]
93pub struct InstrumentedRequestBuilder {
94    inner: reqwest_crate::RequestBuilder,
95    context: Option<Context>,
96}
97
98impl InstrumentedRequestBuilder {
99    /// Creates a new instrumented reqwest request builder.
100    pub fn new(inner: reqwest_crate::RequestBuilder) -> Self {
101        Self {
102            inner,
103            context: None,
104        }
105    }
106
107    /// Sets the OpenTelemetry context for this instrumented request.
108    pub fn context(mut self, context: &Context) -> Self {
109        self.context = Some(context.clone());
110        self
111    }
112
113    /// Sets the optional OpenTelemetry context for this instrumented request.
114    pub fn set_context(mut self, context: Option<&Context>) -> Self {
115        self.context = context.cloned();
116        self
117    }
118
119    /// Sends the request and records an outbound HTTP client span around it.
120    pub fn send(
121        self,
122    ) -> impl Future<Output = Result<reqwest_crate::Response, reqwest_crate::Error>> {
123        let (client, request_result) = self.inner.build_split();
124        let context = self.context;
125
126        async move {
127            let mut request = request_result?;
128            let span_builder = HttpClientSpanBuilder::from_reqwest_request(&request);
129            let span = match context.as_ref() {
130                Some(context) => span_builder.start_with_context(context),
131                None => span_builder.start(),
132            };
133
134            http::inject_context_on_context(span.context(), request.headers_mut());
135
136            let result = client.execute(request).await;
137            match &result {
138                Ok(response) => {
139                    span.end_response(
140                        response.status(),
141                        response.version(),
142                        response.remote_addr(),
143                    );
144                }
145                Err(error) => span.end_error(reqwest_error_type(error), error),
146            }
147            result
148        }
149    }
150}
151
152fn reqwest_error_type(error: &reqwest_crate::Error) -> &'static str {
153    if error.is_timeout() {
154        "timeout"
155    } else if error.is_connect() {
156        "connect"
157    } else if error.is_redirect() {
158        "redirect"
159    } else if error.is_request() {
160        "request"
161    } else if error.is_body() {
162        "body"
163    } else if error.is_decode() {
164        "decode"
165    } else if error.is_builder() {
166        "builder"
167    } else {
168        "_OTHER"
169    }
170}
171
172#[cfg(test)]
173mod tests {
174    use super::ReqwestBuilderInstrument;
175    use crate::{Context, OpenTelemetryLayer, Value, semconv};
176    use assert2::assert;
177    use axum::{
178        Router,
179        extract::State,
180        http::{HeaderMap, StatusCode},
181        response::{IntoResponse, Redirect},
182        routing::get,
183    };
184    use opentelemetry::{
185        global,
186        trace::{Span as _, SpanKind, TraceContextExt, Tracer as _, TracerProvider as _},
187    };
188    use opentelemetry_sdk::{
189        propagation::TraceContextPropagator,
190        trace::{InMemorySpanExporter, SdkTracerProvider as TracerProvider},
191    };
192    use serial_test::serial;
193    use std::sync::{Arc, Mutex};
194    use tokio::{net::TcpListener, task::JoinHandle};
195    use tracing_opentelemetry::OpenTelemetrySpanExt as _;
196    use tracing_subscriber::{Registry, layer::SubscriberExt};
197
198    #[derive(Clone, Default)]
199    struct TestState {
200        traceparents: Arc<Mutex<Vec<(String, String)>>>,
201    }
202
203    impl TestState {
204        fn record(&self, path: &str, headers: &HeaderMap) {
205            if let Some(traceparent) = headers
206                .get("traceparent")
207                .and_then(|value| value.to_str().ok())
208            {
209                self.traceparents
210                    .lock()
211                    .unwrap()
212                    .push((path.to_owned(), traceparent.to_owned()));
213            }
214        }
215
216        fn traceparent_for(&self, path: &str) -> Option<String> {
217            self.traceparents
218                .lock()
219                .unwrap()
220                .iter()
221                .rev()
222                .find(|(recorded_path, _)| recorded_path == path)
223                .map(|(_, traceparent)| traceparent.clone())
224        }
225    }
226
227    #[tokio::test]
228    #[serial]
229    async fn instruments_successful_requests() {
230        let telemetry = configure_test_tracing();
231        let server = spawn_server().await;
232
233        let response = test_client()
234            .get(format!("{}/ok?ready=true", server.base_url))
235            .header(::reqwest::header::USER_AGENT, "telemetry-rust-tests")
236            .instrument()
237            .send()
238            .await
239            .unwrap();
240
241        assert!(response.status() == StatusCode::OK);
242
243        let spans = force_flush_and_get_spans(&telemetry);
244        let span = find_span(&spans, "GET");
245        let traceparent = server.state.traceparent_for("/ok").unwrap();
246        let (trace_id, span_id) = traceparent_ids(&traceparent);
247
248        assert!(span.span_kind == SpanKind::Client);
249        assert!(span.span_context.trace_id().to_string() == trace_id);
250        assert!(span.span_context.span_id().to_string() == span_id);
251        assert!(matches!(span.status, opentelemetry::trace::Status::Unset));
252        assert!(string_attr(span, semconv::HTTP_REQUEST_METHOD) == Some("GET"));
253        assert!(string_attr(span, semconv::URL_SCHEME) == Some("http"));
254        assert!(string_attr(span, semconv::SERVER_ADDRESS) == Some("127.0.0.1"));
255        assert!(string_attr(span, semconv::URL_PATH) == Some("/ok"));
256        assert!(string_attr(span, semconv::URL_QUERY) == Some("ready=true"));
257        assert!(
258            string_attr(span, semconv::USER_AGENT_ORIGINAL)
259                == Some("telemetry-rust-tests")
260        );
261        assert!(i64_attr(span, semconv::HTTP_RESPONSE_STATUS_CODE) == Some(200));
262        assert!(string_attr(span, semconv::NETWORK_PROTOCOL_VERSION).is_some());
263        assert!(string_attr(span, semconv::NETWORK_PEER_ADDRESS).is_some());
264        assert!(i64_attr(span, semconv::NETWORK_PEER_PORT).is_some());
265    }
266
267    #[tokio::test]
268    #[serial]
269    async fn propagates_traceparent_with_client_span_id() {
270        let telemetry = configure_test_tracing();
271        let server = spawn_server().await;
272
273        let tracer = global::tracer("reqwest-propagation-test");
274        let subscriber = Registry::default().with(OpenTelemetryLayer::new(tracer));
275        let _guard = tracing::subscriber::set_default(subscriber);
276
277        let parent = tracing::info_span!("parent");
278        let parent_context = parent.context();
279        let expected_trace_id = parent_context.span().span_context().trace_id();
280
281        tracing::Instrument::instrument(
282            async {
283                test_client()
284                    .get(format!("{}/ok", server.base_url))
285                    .instrument()
286                    .send()
287                    .await
288                    .unwrap();
289            },
290            parent,
291        )
292        .await;
293
294        let spans = force_flush_and_get_spans(&telemetry);
295        let client_span = find_span(&spans, "GET");
296        let traceparent = server.state.traceparent_for("/ok").unwrap();
297        let (trace_id, span_id) = traceparent_ids(&traceparent);
298
299        // The traceparent carries the client span's own span-id, not the parent's.
300        assert!(trace_id == expected_trace_id.to_string());
301        assert!(span_id == client_span.span_context.span_id().to_string());
302    }
303
304    #[tokio::test]
305    #[serial]
306    async fn marks_client_error_responses_as_errors() {
307        let telemetry = configure_test_tracing();
308        let server = spawn_server().await;
309
310        let response = test_client()
311            .get(format!("{}/not-found", server.base_url))
312            .instrument()
313            .send()
314            .await
315            .unwrap();
316
317        assert!(response.status() == StatusCode::NOT_FOUND);
318
319        let spans = force_flush_and_get_spans(&telemetry);
320        let span = find_span(&spans, "GET");
321
322        assert!(matches!(
323            span.status,
324            opentelemetry::trace::Status::Error { .. }
325        ));
326        assert!(i64_attr(span, semconv::HTTP_RESPONSE_STATUS_CODE) == Some(404));
327        assert!(string_attr(span, semconv::ERROR_TYPE) == Some("404"));
328    }
329
330    #[tokio::test]
331    #[serial]
332    async fn marks_server_error_responses_as_errors() {
333        let telemetry = configure_test_tracing();
334        let server = spawn_server().await;
335
336        test_client()
337            .get(format!("{}/server-error", server.base_url))
338            .instrument()
339            .send()
340            .await
341            .unwrap();
342
343        let spans = force_flush_and_get_spans(&telemetry);
344        let span = find_span(&spans, "GET");
345
346        assert!(i64_attr(span, semconv::HTTP_RESPONSE_STATUS_CODE) == Some(500));
347        assert!(string_attr(span, semconv::ERROR_TYPE) == Some("500"));
348        assert!(matches!(
349            span.status,
350            opentelemetry::trace::Status::Error { .. }
351        ));
352    }
353
354    #[tokio::test]
355    #[serial]
356    async fn marks_transport_failures_as_errors() {
357        let telemetry = configure_test_tracing();
358
359        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
360        let addr = listener.local_addr().unwrap();
361        drop(listener);
362
363        let error = test_client()
364            .get(format!("http://{addr}/unavailable"))
365            .instrument()
366            .send()
367            .await
368            .unwrap_err();
369
370        assert!(error.is_connect());
371
372        let spans = force_flush_and_get_spans(&telemetry);
373        let span = find_span(&spans, "GET");
374
375        assert!(matches!(
376            span.status,
377            opentelemetry::trace::Status::Error { .. }
378        ));
379        assert!(string_attr(span, semconv::ERROR_TYPE) == Some("connect"));
380        assert!(i64_attr(span, semconv::HTTP_RESPONSE_STATUS_CODE).is_none());
381    }
382
383    #[tokio::test]
384    #[serial]
385    async fn preserves_original_url_when_redirects_are_followed() {
386        let telemetry = configure_test_tracing();
387        let server = spawn_server().await;
388
389        let response = test_client()
390            .get(format!("{}/redirect?step=1", server.base_url))
391            .instrument()
392            .send()
393            .await
394            .unwrap();
395
396        assert!(response.url().path() == "/final");
397
398        let spans = force_flush_and_get_spans(&telemetry);
399        let span = find_span(&spans, "GET");
400
401        let expected_url = format!("{}/redirect?step=1", server.base_url);
402        assert!(string_attr(span, semconv::URL_FULL) == Some(expected_url.as_str()));
403        assert!(server.state.traceparent_for("/redirect").is_some());
404        assert!(server.state.traceparent_for("/final").is_some());
405    }
406
407    #[tokio::test]
408    #[serial]
409    async fn uses_explicit_parent_context_when_provided() {
410        let telemetry = configure_test_tracing();
411        let server = spawn_server().await;
412        let tracer = telemetry.provider.tracer("reqwest-tests");
413        let explicit_parent = tracer.start("explicit-parent");
414        let explicit_parent_span_id = explicit_parent.span_context().span_id();
415        let explicit_parent_cx = Context::current_with_span(explicit_parent);
416        let tracing_tracer = telemetry.provider.tracer("tracing-tests");
417        let subscriber = Registry::default()
418            .with(tracing_opentelemetry::layer().with_tracer(tracing_tracer));
419        let guard = tracing::subscriber::set_default(subscriber);
420        let current_parent = tracing::info_span!("current-parent");
421
422        tracing::Instrument::instrument(
423            async {
424                test_client()
425                    .get(format!("{}/ok", server.base_url))
426                    .instrument()
427                    .context(&explicit_parent_cx)
428                    .send()
429                    .await
430                    .unwrap();
431            },
432            current_parent,
433        )
434        .await;
435
436        drop(guard);
437        explicit_parent_cx.span().end();
438
439        let spans = force_flush_and_get_spans(&telemetry);
440        let reqwest_span = find_span(&spans, "GET");
441        let current_span = find_span(&spans, "current-parent");
442
443        assert!(reqwest_span.parent_span_id == explicit_parent_span_id);
444        assert!(reqwest_span.parent_span_id != current_span.span_context.span_id());
445    }
446
447    #[tokio::test]
448    #[serial]
449    async fn does_not_emit_span_for_invalid_builder() {
450        let telemetry = configure_test_tracing();
451
452        let result = test_client()
453            .get("http://example.com")
454            .header("bad\nheader", "value")
455            .instrument()
456            .send()
457            .await;
458
459        assert!(result.is_err());
460        assert!(result.unwrap_err().is_builder());
461
462        let spans = force_flush_and_get_spans(&telemetry);
463        assert!(client_spans(&spans).is_empty());
464    }
465
466    // --- helpers ---
467
468    struct TestServer {
469        base_url: String,
470        state: TestState,
471        _handle: JoinHandle<()>,
472    }
473
474    async fn spawn_server() -> TestServer {
475        async fn ok(
476            State(state): State<TestState>,
477            headers: HeaderMap,
478        ) -> impl IntoResponse {
479            state.record("/ok", &headers);
480            StatusCode::OK
481        }
482
483        async fn not_found(
484            State(state): State<TestState>,
485            headers: HeaderMap,
486        ) -> impl IntoResponse {
487            state.record("/not-found", &headers);
488            StatusCode::NOT_FOUND
489        }
490
491        async fn server_error(
492            State(state): State<TestState>,
493            headers: HeaderMap,
494        ) -> impl IntoResponse {
495            state.record("/server-error", &headers);
496            StatusCode::INTERNAL_SERVER_ERROR
497        }
498
499        async fn redirect(
500            State(state): State<TestState>,
501            headers: HeaderMap,
502        ) -> impl IntoResponse {
503            state.record("/redirect", &headers);
504            Redirect::temporary("/final")
505        }
506
507        async fn final_route(
508            State(state): State<TestState>,
509            headers: HeaderMap,
510        ) -> impl IntoResponse {
511            state.record("/final", &headers);
512            StatusCode::OK
513        }
514
515        let state = TestState::default();
516        let app = Router::new()
517            .route("/ok", get(ok))
518            .route("/not-found", get(not_found))
519            .route("/server-error", get(server_error))
520            .route("/redirect", get(redirect))
521            .route("/final", get(final_route))
522            .with_state(state.clone());
523
524        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
525        let addr = listener.local_addr().unwrap();
526        let handle = tokio::spawn(async move {
527            axum::serve(listener, app).await.unwrap();
528        });
529
530        TestServer {
531            base_url: format!("http://{addr}"),
532            state,
533            _handle: handle,
534        }
535    }
536
537    fn configure_test_tracing() -> TestTelemetry {
538        let exporter = InMemorySpanExporter::default();
539        let provider = TracerProvider::builder()
540            .with_simple_exporter(exporter.clone())
541            .build();
542        global::set_tracer_provider(provider.clone());
543        global::set_text_map_propagator(TraceContextPropagator::new());
544        TestTelemetry { exporter, provider }
545    }
546
547    fn test_client() -> ::reqwest::Client {
548        ::reqwest::Client::builder().no_proxy().build().unwrap()
549    }
550
551    fn force_flush_and_get_spans(
552        telemetry: &TestTelemetry,
553    ) -> Vec<opentelemetry_sdk::trace::SpanData> {
554        telemetry.provider.force_flush().unwrap();
555        telemetry.exporter.get_finished_spans().unwrap()
556    }
557
558    fn client_spans(
559        spans: &[opentelemetry_sdk::trace::SpanData],
560    ) -> Vec<&opentelemetry_sdk::trace::SpanData> {
561        spans
562            .iter()
563            .filter(|span| span.span_kind == SpanKind::Client)
564            .collect()
565    }
566
567    fn find_span<'a>(
568        spans: &'a [opentelemetry_sdk::trace::SpanData],
569        name: &str,
570    ) -> &'a opentelemetry_sdk::trace::SpanData {
571        spans.iter().find(|span| span.name == name).unwrap()
572    }
573
574    fn string_attr<'a>(
575        span: &'a opentelemetry_sdk::trace::SpanData,
576        key: &str,
577    ) -> Option<&'a str> {
578        match attr(span, key) {
579            Some(Value::String(value)) => Some(value.as_str()),
580            _ => None,
581        }
582    }
583
584    fn i64_attr(span: &opentelemetry_sdk::trace::SpanData, key: &str) -> Option<i64> {
585        match attr(span, key) {
586            Some(Value::I64(value)) => Some(*value),
587            _ => None,
588        }
589    }
590
591    fn attr<'a>(
592        span: &'a opentelemetry_sdk::trace::SpanData,
593        key: &str,
594    ) -> Option<&'a Value> {
595        span.attributes
596            .iter()
597            .find(|kv| kv.key.as_str() == key)
598            .map(|kv| &kv.value)
599    }
600
601    fn traceparent_ids(traceparent: &str) -> (&str, &str) {
602        let mut parts = traceparent.split('-');
603        let _version = parts.next().unwrap();
604        let trace_id = parts.next().unwrap();
605        let span_id = parts.next().unwrap();
606        (trace_id, span_id)
607    }
608
609    struct TestTelemetry {
610        exporter: InMemorySpanExporter,
611        provider: TracerProvider,
612    }
613
614    impl Drop for TestTelemetry {
615        fn drop(&mut self) {
616            let _ = self.provider.shutdown();
617        }
618    }
619}