Skip to main content

modkit/http/
client.rs

1//! Traced HTTP client that automatically injects OpenTelemetry context
2//!
3//! This module provides a wrapper around `reqwest::Client` that automatically
4//! injects trace context headers (traceparent, tracestate) for distributed tracing.
5
6use crate::http::otel;
7use tracing::Level;
8
9/// A traced HTTP client that automatically injects OpenTelemetry trace context
10/// into outgoing requests for distributed tracing.
11#[derive(Clone)]
12pub struct TracedClient {
13    inner: reqwest::Client,
14}
15
16impl TracedClient {
17    /// Create a new `TracedClient` wrapping the provided `reqwest::Client`
18    #[must_use]
19    pub fn new(inner: reqwest::Client) -> Self {
20        Self { inner }
21    }
22
23    /// Execute a built `reqwest::Request`, injecting trace headers from the current span.
24    /// Creates a span for the outgoing HTTP request and injects the current trace context.
25    ///
26    /// # Errors
27    /// Returns a reqwest error if the request fails.
28    pub async fn execute(&self, req: reqwest::Request) -> reqwest::Result<reqwest::Response> {
29        let url = req.url().clone();
30        let method = req.method().clone();
31
32        let span = tracing::span!(
33            Level::INFO, "outgoing_http",
34            http.method = %method,
35            http.url = %url,
36            otel.kind = "client",
37        );
38        let _g = span.enter();
39
40        // Inject trace context into headers using simplified approach
41        let req = {
42            let mut req = req.try_clone().unwrap_or(req);
43            otel::inject_current_span(req.headers_mut());
44            req
45        };
46
47        let response = self.inner.execute(req).await?;
48
49        // Record response status in span
50        span.record("http.status_code", response.status().as_u16());
51        if response.status().is_client_error() || response.status().is_server_error() {
52            span.record("error", true);
53        }
54
55        Ok(response)
56    }
57
58    /// Convenience method for GET requests.
59    ///
60    /// # Errors
61    /// Returns a reqwest error if the request fails.
62    pub async fn get(&self, url: &str) -> reqwest::Result<reqwest::Response> {
63        let req = self.inner.get(url).build()?;
64        self.execute(req).await
65    }
66
67    /// Convenience method for POST requests.
68    ///
69    /// # Errors
70    /// Returns a reqwest error if the request fails.
71    pub async fn post(&self, url: &str) -> reqwest::Result<reqwest::Response> {
72        let req = self.inner.post(url).build()?;
73        self.execute(req).await
74    }
75
76    /// Convenience method for PUT requests.
77    ///
78    /// # Errors
79    /// Returns a reqwest error if the request fails.
80    pub async fn put(&self, url: &str) -> reqwest::Result<reqwest::Response> {
81        let req = self.inner.put(url).build()?;
82        self.execute(req).await
83    }
84
85    /// Convenience method for PATCH requests.
86    ///
87    /// # Errors
88    /// Returns a reqwest error if the request fails.
89    pub async fn patch(&self, url: &str) -> reqwest::Result<reqwest::Response> {
90        let req = self.inner.patch(url).build()?;
91        self.execute(req).await
92    }
93
94    /// Convenience method for DELETE requests.
95    ///
96    /// # Errors
97    /// Returns a reqwest error if the request fails.
98    pub async fn delete(&self, url: &str) -> reqwest::Result<reqwest::Response> {
99        let req = self.inner.delete(url).build()?;
100        self.execute(req).await
101    }
102
103    /// Get a reference to the underlying `reqwest::Client` for advanced usage
104    #[must_use]
105    pub fn inner(&self) -> &reqwest::Client {
106        &self.inner
107    }
108
109    /// Create a GET request builder
110    pub fn request(&self, method: reqwest::Method, url: &str) -> reqwest::RequestBuilder {
111        self.inner.request(method, url)
112    }
113}
114
115impl From<reqwest::Client> for TracedClient {
116    fn from(c: reqwest::Client) -> Self {
117        Self::new(c)
118    }
119}
120
121impl Default for TracedClient {
122    fn default() -> Self {
123        Self::new(reqwest::Client::new())
124    }
125}
126
127#[cfg(test)]
128#[cfg_attr(coverage_nightly, coverage(off))]
129mod tests {
130    use super::*;
131    use httpmock::prelude::*;
132
133    #[tokio::test]
134    async fn test_traced_client_basic_functionality() {
135        let client = TracedClient::default();
136
137        // This test doesn't require a real server, just checks that the client can be created
138        // and methods exist. We'll test actual tracing functionality with httpmock.
139        assert!(client.inner().get("https://example.com").build().is_ok());
140    }
141
142    #[tokio::test]
143    async fn test_traced_client_executes_request() {
144        // Test that the client works without panicking (OTEL integration tested separately)
145        let server = MockServer::start();
146        let _m = server.mock(|when, then| {
147            when.method(GET).path("/ping");
148            then.status(200).body("ok");
149        });
150
151        let client = TracedClient::from(reqwest::Client::new());
152        let url = format!("{}/ping", server.base_url());
153        let resp = client.get(&url).await.unwrap();
154
155        assert!(resp.status().is_success());
156        // Note: With OTEL enabled, headers are injected if context is set up
157        // Without OTEL, inject_current_span is a no-op
158    }
159
160    #[tokio::test]
161    async fn test_all_http_methods() {
162        let client = TracedClient::default();
163
164        // Just verify all methods can be called (they'll fail without a server, but that's ok)
165        let url = "http://example.com/test";
166
167        // These will error due to no server, but we're just testing the API exists
168        let _ = client.get(url).await;
169        let _ = client.post(url).await;
170        let _ = client.put(url).await;
171        let _ = client.patch(url).await;
172        let _ = client.delete(url).await;
173    }
174}