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