reqwest_tracing/
reqwest_otel_span_builder.rs

1use std::borrow::Cow;
2
3use http::Extensions;
4use matchit::Router;
5use reqwest::{Request, Response, StatusCode as RequestStatusCode, Url};
6use reqwest_middleware::{Error, Result};
7use tracing::{warn, Span};
8
9use crate::reqwest_otel_span;
10
11/// The `http.request.method` field added to the span by [`reqwest_otel_span`]
12pub const HTTP_REQUEST_METHOD: &str = "http.request.method";
13/// The `url.scheme` field added to the span by [`reqwest_otel_span`]
14pub const URL_SCHEME: &str = "url.scheme";
15/// The `server.address` field added to the span by [`reqwest_otel_span`]
16pub const SERVER_ADDRESS: &str = "server.address";
17/// The `server.port` field added to the span by [`reqwest_otel_span`]
18pub const SERVER_PORT: &str = "server.port";
19/// The `url.full` field added to the span by [`reqwest_otel_span`]
20pub const URL_FULL: &str = "url.full";
21/// The `user_agent.original` field added to the span by [`reqwest_otel_span`]
22pub const USER_AGENT_ORIGINAL: &str = "user_agent.original";
23/// The `otel.kind` field added to the span by [`reqwest_otel_span`]
24pub const OTEL_KIND: &str = "otel.kind";
25/// The `otel.name` field added to the span by [`reqwest_otel_span`]
26pub const OTEL_NAME: &str = "otel.name";
27/// The `otel.status_code` field added to the span by [`reqwest_otel_span`]
28pub const OTEL_STATUS_CODE: &str = "otel.status_code";
29/// The `http.response.status_code` field added to the span by [`reqwest_otel_span`]
30pub const HTTP_RESPONSE_STATUS_CODE: &str = "http.response.status_code";
31/// The `error.message` field added to the span by [`reqwest_otel_span`]
32pub const ERROR_MESSAGE: &str = "error.message";
33/// The `error.cause_chain` field added to the span by [`reqwest_otel_span`]
34pub const ERROR_CAUSE_CHAIN: &str = "error.cause_chain";
35
36/// The `http.method` field added to the span by [`reqwest_otel_span`]
37#[cfg(feature = "deprecated_attributes")]
38pub const HTTP_METHOD: &str = "http.method";
39/// The `http.scheme` field added to the span by [`reqwest_otel_span`]
40#[cfg(feature = "deprecated_attributes")]
41pub const HTTP_SCHEME: &str = "http.scheme";
42/// The `http.host` field added to the span by [`reqwest_otel_span`]
43#[cfg(feature = "deprecated_attributes")]
44pub const HTTP_HOST: &str = "http.host";
45/// The `http.url` field added to the span by [`reqwest_otel_span`]
46#[cfg(feature = "deprecated_attributes")]
47pub const HTTP_URL: &str = "http.url";
48/// The `host.port` field added to the span by [`reqwest_otel_span`]
49#[cfg(feature = "deprecated_attributes")]
50pub const NET_HOST_PORT: &str = "net.host.port";
51/// The `http.status_code` field added to the span by [`reqwest_otel_span`]
52#[cfg(feature = "deprecated_attributes")]
53pub const HTTP_STATUS_CODE: &str = "http.status_code";
54/// The `http.user_agent` added to the span by [`reqwest_otel_span`]
55#[cfg(feature = "deprecated_attributes")]
56pub const HTTP_USER_AGENT: &str = "http.user_agent";
57
58/// [`ReqwestOtelSpanBackend`] allows you to customise the span attached by
59/// [`TracingMiddleware`] to incoming requests.
60///
61/// Check out [`reqwest_otel_span`] documentation for examples.
62///
63/// [`TracingMiddleware`]: crate::middleware::TracingMiddleware.
64pub trait ReqwestOtelSpanBackend {
65    /// Initialized a new span before the request is executed.
66    fn on_request_start(req: &Request, extension: &mut Extensions) -> Span;
67
68    /// Runs after the request call has executed.
69    fn on_request_end(span: &Span, outcome: &Result<Response>, extension: &mut Extensions);
70}
71
72/// Populates default success/failure fields for a given [`reqwest_otel_span!`] span.
73#[inline]
74pub fn default_on_request_end(span: &Span, outcome: &Result<Response>) {
75    match outcome {
76        Ok(res) => default_on_request_success(span, res),
77        Err(err) => default_on_request_failure(span, err),
78    }
79}
80
81#[cfg(feature = "deprecated_attributes")]
82fn get_header_value(key: &str, headers: &reqwest::header::HeaderMap) -> String {
83    let header_default = &reqwest::header::HeaderValue::from_static("");
84    format!("{:?}", headers.get(key).unwrap_or(header_default)).replace('"', "")
85}
86
87/// Populates default success fields for a given [`reqwest_otel_span!`] span.
88#[inline]
89pub fn default_on_request_success(span: &Span, response: &Response) {
90    let span_status = get_span_status(response.status());
91    if let Some(span_status) = span_status {
92        span.record(OTEL_STATUS_CODE, span_status);
93    }
94    span.record(HTTP_RESPONSE_STATUS_CODE, response.status().as_u16());
95    #[cfg(feature = "deprecated_attributes")]
96    {
97        let user_agent = get_header_value("user_agent", response.headers());
98        span.record(HTTP_STATUS_CODE, response.status().as_u16());
99        span.record(HTTP_USER_AGENT, user_agent.as_str());
100    }
101}
102
103/// Populates default failure fields for a given [`reqwest_otel_span!`] span.
104#[inline]
105pub fn default_on_request_failure(span: &Span, e: &Error) {
106    let error_message = e.to_string();
107    let error_cause_chain = format!("{:?}", e);
108    span.record(OTEL_STATUS_CODE, "ERROR");
109    span.record(ERROR_MESSAGE, error_message.as_str());
110    span.record(ERROR_CAUSE_CHAIN, error_cause_chain.as_str());
111    if let Error::Reqwest(e) = e {
112        if let Some(status) = e.status() {
113            span.record(HTTP_RESPONSE_STATUS_CODE, status.as_u16());
114            #[cfg(feature = "deprecated_attributes")]
115            {
116                span.record(HTTP_STATUS_CODE, status.as_u16());
117            }
118        }
119    }
120}
121
122/// Determine the name of the span that should be associated with this request.
123///
124/// This tries to be PII safe by default, not including any path information unless
125/// specifically opted in using either [`OtelName`] or [`OtelPathNames`]
126#[inline]
127pub fn default_span_name<'a>(req: &'a Request, ext: &'a Extensions) -> Cow<'a, str> {
128    if let Some(name) = ext.get::<OtelName>() {
129        Cow::Borrowed(name.0.as_ref())
130    } else if let Some(path_names) = ext.get::<OtelPathNames>() {
131        path_names
132            .find(req.url().path())
133            .map(|path| Cow::Owned(format!("{} {}", req.method(), path)))
134            .unwrap_or_else(|| {
135                warn!("no OTEL path name found");
136                Cow::Owned(format!("{} UNKNOWN", req.method().as_str()))
137            })
138    } else {
139        Cow::Borrowed(req.method().as_str())
140    }
141}
142
143/// The default [`ReqwestOtelSpanBackend`] for [`TracingMiddleware`]. Note that it doesn't include
144/// the `url.full` field in spans, you can use [`SpanBackendWithUrl`] to add it.
145///
146/// [`TracingMiddleware`]: crate::middleware::TracingMiddleware
147pub struct DefaultSpanBackend;
148
149impl ReqwestOtelSpanBackend for DefaultSpanBackend {
150    fn on_request_start(req: &Request, ext: &mut Extensions) -> Span {
151        let name = default_span_name(req, ext);
152        reqwest_otel_span!(name = name, req)
153    }
154
155    fn on_request_end(span: &Span, outcome: &Result<Response>, _: &mut Extensions) {
156        default_on_request_end(span, outcome)
157    }
158}
159
160/// Similar to [`DefaultSpanBackend`] but also adds the `url.full` attribute to request spans.
161///
162/// [`TracingMiddleware`]: crate::middleware::TracingMiddleware
163pub struct SpanBackendWithUrl;
164
165impl ReqwestOtelSpanBackend for SpanBackendWithUrl {
166    fn on_request_start(req: &Request, ext: &mut Extensions) -> Span {
167        let name = default_span_name(req, ext);
168        let url = remove_credentials(req.url());
169        let span = reqwest_otel_span!(name = name, req, url.full = %url);
170        #[cfg(feature = "deprecated_attributes")]
171        {
172            span.record(HTTP_URL, url.to_string());
173        }
174        span
175    }
176
177    fn on_request_end(span: &Span, outcome: &Result<Response>, _: &mut Extensions) {
178        default_on_request_end(span, outcome)
179    }
180}
181
182/// HTTP Mapping <https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/trace/semantic_conventions/http.md#status>
183///
184/// Maps the the http status to an Opentelemetry span status following the the specified convention above.
185fn get_span_status(request_status: RequestStatusCode) -> Option<&'static str> {
186    match request_status.as_u16() {
187        // Span Status MUST be left unset if HTTP status code was in the 1xx, 2xx or 3xx ranges, unless there was
188        // another error (e.g., network error receiving the response body; or 3xx codes with max redirects exceeded),
189        // in which case status MUST be set to Error.
190        100..=399 => None,
191        // For HTTP status codes in the 4xx range span status MUST be left unset in case of SpanKind.SERVER and MUST be
192        // set to Error in case of SpanKind.CLIENT.
193        400..=499 => Some("ERROR"),
194        // For HTTP status codes in the 5xx range, as well as any other code the client failed to interpret, span
195        // status MUST be set to Error.
196        _ => Some("ERROR"),
197    }
198}
199
200/// [`OtelName`] allows customisation of the name of the spans created by
201/// [`DefaultSpanBackend`] and [`SpanBackendWithUrl`].
202///
203/// Usage:
204/// ```no_run
205/// # use reqwest_middleware::Result;
206/// use reqwest_middleware::{ClientBuilder, Extension};
207/// use reqwest_tracing::{
208///     TracingMiddleware, OtelName
209/// };
210/// # async fn example() -> Result<()> {
211/// let reqwest_client = reqwest::Client::builder().build().unwrap();
212/// let client = ClientBuilder::new(reqwest_client)
213///    // Inserts the extension before the request is started
214///    .with_init(Extension(OtelName("my-client".into())))
215///    // Makes use of that extension to specify the otel name
216///    .with(TracingMiddleware::default())
217///    .build();
218///
219/// let resp = client.get("https://truelayer.com").send().await.unwrap();
220///
221/// // Or specify it on the individual request (will take priority)
222/// let resp = client.post("https://api.truelayer.com/payment")
223///     .with_extension(OtelName("POST /payment".into()))
224///    .send()
225///    .await
226///    .unwrap();
227/// # Ok(())
228/// # }
229/// ```
230#[derive(Clone)]
231pub struct OtelName(pub Cow<'static, str>);
232
233/// [`OtelPathNames`] allows including templated paths in the spans created by
234/// [`DefaultSpanBackend`] and [`SpanBackendWithUrl`].
235///
236/// When creating spans this can be used to try to match the path against some
237/// known paths. If the path matches value returned is the templated path. This
238/// can be used in span names as it will not contain values that would
239/// increase the cardinality.
240///
241/// ```
242/// /// # use reqwest_middleware::Result;
243/// use reqwest_middleware::{ClientBuilder, Extension};
244/// use reqwest_tracing::{
245///     TracingMiddleware, OtelPathNames
246/// };
247/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
248/// let reqwest_client = reqwest::Client::builder().build()?;
249/// let client = ClientBuilder::new(reqwest_client)
250///    // Inserts the extension before the request is started
251///    .with_init(Extension(OtelPathNames::known_paths(["/payment/{paymentId}"])?))
252///    // Makes use of that extension to specify the otel name
253///    .with(TracingMiddleware::default())
254///    .build();
255///
256/// let resp = client.get("https://truelayer.com/payment/id-123").send().await?;
257///
258/// // Or specify it on the individual request (will take priority)
259/// let resp = client.post("https://api.truelayer.com/payment/id-123/authorization-flow")
260///     .with_extension(OtelPathNames::known_paths(["/payment/{paymentId}/authorization-flow"])?)
261///    .send()
262///    .await?;
263/// # Ok(())
264/// # }
265/// ```
266#[derive(Clone)]
267pub struct OtelPathNames(matchit::Router<String>);
268
269impl OtelPathNames {
270    /// Create a new [`OtelPathNames`] from a set of known paths.
271    ///
272    /// Paths in this set will be found with `find`.
273    ///
274    /// Paths can have different parameters:
275    /// - Named parameters like `:paymentId` match anything until the next `/` or the end of the path.
276    /// - Catch-all parameters start with `*` and match everything after the `/`. They must be at the end of the route.
277    /// ```
278    /// # use reqwest_tracing::OtelPathNames;
279    /// OtelPathNames::known_paths([
280    ///     "/",
281    ///     "/payment",
282    ///     "/payment/{paymentId}",
283    ///     "/payment/{paymentId}/*action",
284    /// ]).unwrap();
285    /// ```
286    pub fn known_paths<Paths, Path>(paths: Paths) -> anyhow::Result<Self>
287    where
288        Paths: IntoIterator<Item = Path>,
289        Path: Into<String>,
290    {
291        let mut router = Router::new();
292        for path in paths {
293            let path = path.into();
294            router.insert(path.clone(), path)?;
295        }
296
297        Ok(Self(router))
298    }
299
300    /// Find the templated path from the actual path.
301    ///
302    /// Returns the templated path if a match is found.
303    ///
304    /// ```
305    /// # use reqwest_tracing::OtelPathNames;
306    /// let path_names = OtelPathNames::known_paths(["/payment/{paymentId}"]).unwrap();
307    /// let path = path_names.find("/payment/payment-id-123");
308    /// assert_eq!(path, Some("/payment/{paymentId}"));
309    /// ```
310    pub fn find(&self, path: &str) -> Option<&str> {
311        self.0.at(path).map(|mtch| mtch.value.as_str()).ok()
312    }
313}
314
315/// `DisableOtelPropagation` disables opentelemetry header propagation, while still tracing the HTTP request.
316///
317/// By default, the [`TracingMiddleware`](super::TracingMiddleware) middleware will also propagate any opentelemtry
318/// contexts to the server. For any external facing requests, this can be problematic and it should be disabled.
319///
320/// Usage:
321/// ```no_run
322/// # use reqwest_middleware::Result;
323/// use reqwest_middleware::{ClientBuilder, Extension};
324/// use reqwest_tracing::{
325///     TracingMiddleware, DisableOtelPropagation
326/// };
327/// # async fn example() -> Result<()> {
328/// let reqwest_client = reqwest::Client::builder().build().unwrap();
329/// let client = ClientBuilder::new(reqwest_client)
330///    // Inserts the extension before the request is started
331///    .with_init(Extension(DisableOtelPropagation))
332///    // Makes use of that extension to specify the otel name
333///    .with(TracingMiddleware::default())
334///    .build();
335///
336/// let resp = client.get("https://truelayer.com").send().await.unwrap();
337///
338/// // Or specify it on the individual request (will take priority)
339/// let resp = client.post("https://api.truelayer.com/payment")
340///     .with_extension(DisableOtelPropagation)
341///     .send()
342///     .await
343///     .unwrap();
344/// # Ok(())
345/// # }
346/// ```
347#[derive(Clone)]
348pub struct DisableOtelPropagation;
349
350/// Removes the username and/or password parts of the url, if present.
351fn remove_credentials(url: &Url) -> Cow<'_, str> {
352    if !url.username().is_empty() || url.password().is_some() {
353        let mut url = url.clone();
354        // Errors settings username/password are set when the URL can't have credentials, so
355        // they're just ignored.
356        url.set_username("")
357            .and_then(|_| url.set_password(None))
358            .ok();
359        url.to_string().into()
360    } else {
361        url.as_ref().into()
362    }
363}
364
365#[cfg(test)]
366mod tests {
367    use super::*;
368
369    use reqwest::header::{HeaderMap, HeaderValue};
370
371    fn get_header_value(key: &str, headers: &HeaderMap) -> String {
372        let header_default = &HeaderValue::from_static("");
373        format!("{:?}", headers.get(key).unwrap_or(header_default)).replace('"', "")
374    }
375
376    #[test]
377    fn get_header_value_for_span_attribute() {
378        let expect = "IMPORTANT_HEADER";
379        let mut header_map = HeaderMap::new();
380        header_map.insert("test", expect.parse().unwrap());
381
382        let value = get_header_value("test", &header_map);
383        assert_eq!(value, expect);
384    }
385
386    #[test]
387    fn remove_credentials_from_url_without_credentials_is_noop() {
388        let url = "http://nocreds.com/".parse().unwrap();
389        let clean = remove_credentials(&url);
390        assert_eq!(clean, "http://nocreds.com/");
391    }
392
393    #[test]
394    fn remove_credentials_removes_username_only() {
395        let url = "http://user@withuser.com/".parse().unwrap();
396        let clean = remove_credentials(&url);
397        assert_eq!(clean, "http://withuser.com/");
398    }
399
400    #[test]
401    fn remove_credentials_removes_password_only() {
402        let url = "http://:123@withpwd.com/".parse().unwrap();
403        let clean = remove_credentials(&url);
404        assert_eq!(clean, "http://withpwd.com/");
405    }
406
407    #[test]
408    fn remove_credentials_removes_username_and_password() {
409        let url = "http://user:123@both.com/".parse().unwrap();
410        let clean = remove_credentials(&url);
411        assert_eq!(clean, "http://both.com/");
412    }
413}