reqwest_datadog_tracing/
reqwest_otel_span_builder.rs

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