reqwest_metrics/
lib.rs

1/*!
2[Metrics.rs](https://docs.rs/metrics/latest/metrics/) integration for [reqwest](https://docs.rs/reqwest/latest/reqwest/) using [reqwest-middleware](https://docs.rs/reqwest-middleware/latest/reqwest_middleware/)
3
4## Features
5
6* Adheres to [Open Telemetry HTTP Client Semantic Conventions](https://opentelemetry.io/docs/specs/semconv/http/http-metrics/#http-client)
7* Customizable labels
8
9# Usage
10
11```rust
12# use reqwest_middleware::ClientBuilder;
13# use reqwest_metrics::MetricsMiddleware;
14let client = ClientBuilder::new(reqwest::Client::new())
15    .with(MetricsMiddleware::new())
16    .build();
17```
18
19## Configuration
20
21### Overriding label names
22
23```rust
24# use reqwest_middleware::ClientBuilder;
25# use reqwest_metrics::MetricsMiddleware;
26let client = ClientBuilder::new(reqwest::Client::new())
27    .with(
28        MetricsMiddleware::builder()
29            .http_request_method_label("method")
30            .http_response_status_label("status")
31            .server_address_label("host")
32            .build(),
33    )
34    .build();
35```
36
37Supported metrics:
38* [`http.client.request.duration`](https://opentelemetry.io/docs/specs/semconv/http/http-metrics/#metric-httpclientrequestduration)
39* [`http.client.request.body.size`](https://opentelemetry.io/docs/specs/semconv/http/http-metrics/#metric-httpclientrequestbodysize)
40* [`http.client.response.body.size`](https://opentelemetry.io/docs/specs/semconv/http/http-metrics/#metric-httpclientresponsebodysize)
41
42Supported labels:
43* `http_request_method`
44* `server_address`
45* `server_port`
46* `error_type`
47* `http_response_status_code`
48* `network_protocol_name`
49* `network_protocol_version`
50* `url_scheme`
51
52## Motivation
53
54This crate is heavily inspired by the [HTTP Client metrics](https://docs.spring.io/spring-boot/reference/actuator/metrics.html#actuator.metrics.supported.http-clients) provided by Spring. This crate aims to provide the same functionality while adhereing to Otel semantic conventions.
55
56
57*/
58
59#![deny(missing_docs)]
60
61use std::{borrow::Cow, time::Instant};
62
63use http::{Extensions, Method};
64use metrics::{describe_histogram, histogram, Unit};
65use reqwest_middleware::{
66    reqwest::{Request, Response},
67    Error, Middleware, Next, Result,
68};
69
70// Defaults should follow Open Telemetry when possible
71// https://opentelemetry.io/docs/specs/semconv/http/http-metrics/#http-client
72const HTTP_CLIENT_REQUEST_DURATION: &str = "http.client.request.duration";
73const HTTP_CLIENT_REQUEST_BODY_SIZE: &str = "http.client.request.body.size";
74const HTTP_CLIENT_RESPONSE_BODY_SIZE: &str = "http.client.response.body.size";
75// Labels
76const HTTP_REQUEST_METHOD: &str = "http.request.method";
77const SERVER_ADDRESS: &str = "server.address";
78const SERVER_PORT: &str = "server.port";
79const ERROR_TYPE: &str = "error.type";
80const HTTP_RESPONSE_STATUS_CODE: &str = "http.response.status_code";
81const NETWORK_PROTOCOL_NAME: &str = "network.protocol.name";
82const NETWORK_PROTOCOL_VERSION: &str = "network.protocol.version";
83const URL_SCHEME: &str = "url.scheme";
84
85/// Middleware to handle emitting HTTP metrics for a reqwest client
86/// NOTE: Creating a `[MetricMiddleware]` will describe a histogram on construction.
87#[derive(Debug, Clone)]
88pub struct MetricsMiddleware {
89    label_names: LabelNames,
90}
91
92impl MetricsMiddleware {
93    /// Create a new [`MetricsMiddleware`] with default labels.
94    pub fn new() -> Self {
95        Self::new_inner(LabelNames::default())
96    }
97
98    fn new_inner(label_names: LabelNames) -> Self {
99        describe_histogram!(
100            HTTP_CLIENT_REQUEST_DURATION,
101            Unit::Seconds,
102            "Duration of HTTP client requests."
103        );
104        describe_histogram!(
105            HTTP_CLIENT_REQUEST_BODY_SIZE,
106            Unit::Bytes,
107            "Size of HTTP client request bodies."
108        );
109        describe_histogram!(
110            HTTP_CLIENT_RESPONSE_BODY_SIZE,
111            Unit::Bytes,
112            "Size of HTTP client response bodies."
113        );
114        Self { label_names }
115    }
116
117    /// Create a new [`MetricsMiddlewareBuilder`] to create a customized [`MetricsMiddleware`]
118    pub fn builder() -> MetricsMiddlewareBuilder {
119        MetricsMiddlewareBuilder::new()
120    }
121}
122
123#[derive(Debug, Clone)]
124struct LabelNames {
125    http_request_method: String,
126    server_address: String,
127    server_port: String,
128    error_type: String,
129    http_response_status: String,
130    network_protocol_name: String,
131    network_protocol_version: String,
132    url_scheme: String,
133}
134
135impl Default for LabelNames {
136    fn default() -> Self {
137        Self {
138            http_request_method: HTTP_REQUEST_METHOD.to_string(),
139            server_address: SERVER_ADDRESS.to_string(),
140            server_port: SERVER_PORT.to_string(),
141            error_type: ERROR_TYPE.to_string(),
142            http_response_status: HTTP_RESPONSE_STATUS_CODE.to_string(),
143            network_protocol_name: NETWORK_PROTOCOL_NAME.to_string(),
144            network_protocol_version: NETWORK_PROTOCOL_VERSION.to_string(),
145            url_scheme: URL_SCHEME.to_string(),
146        }
147    }
148}
149
150impl Default for MetricsMiddleware {
151    fn default() -> Self {
152        Self::new()
153    }
154}
155
156/// Builder for [`MetricsMiddleware`]
157#[derive(Debug, Clone)]
158pub struct MetricsMiddlewareBuilder {
159    label_names: LabelNames,
160}
161
162macro_rules! label_setters {
163    // Match one or more method definitions
164    (
165        $(
166            // For each definition, capture the method name, field name, and doc comment
167            $(#[$attr:meta])*
168            $method_name:ident, $field_name:ident
169        );+
170        $(;)?
171    ) => {
172        $(
173            $(#[$attr])*
174            pub fn $method_name<T: Into<String>>(&mut self, label: T) -> &mut Self {
175                self.label_names.$field_name = label.into();
176                self
177            }
178        )+
179    };
180}
181impl MetricsMiddlewareBuilder {
182    /// Create a new [`MetricsMiddlewareBuilder`]
183    pub fn new() -> Self {
184        Self {
185            label_names: LabelNames::default(),
186        }
187    }
188
189    label_setters! {
190        /// Rename the `http.request.method` label.
191        http_request_method_label, http_request_method;
192        /// Rename the `server.address` label.
193        server_address_label, server_address;
194        /// Rename the `server.port` label.
195        server_port_label, server_port;
196        /// Rename the `error.type` label.
197        error_type_label, error_type;
198        /// Rename the `http.response.status` label.
199        http_response_status_label, http_response_status;
200        /// Rename the `network.protocol.name` label.
201        network_protocol_name_label, network_protocol_name;
202        /// Rename the `network.protocol.version` label.
203        network_protocol_version_label, network_protocol_name;
204        /// Rename the `url.scheme` label.
205        url_scheme_label, url_scheme
206    }
207
208    /// Builds a [`MetricsMiddleware`]
209    pub fn build(&self) -> MetricsMiddleware {
210        MetricsMiddleware::new_inner(self.label_names.clone())
211    }
212}
213
214impl Default for MetricsMiddlewareBuilder {
215    fn default() -> Self {
216        Self::new()
217    }
218}
219
220#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
221#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
222impl Middleware for MetricsMiddleware {
223    async fn handle(
224        &self,
225        req: Request,
226        extensions: &mut Extensions,
227        next: Next<'_>,
228    ) -> Result<Response> {
229        let http_request_method = http_request_method(&req);
230        let url_scheme = url_scheme(&req);
231        let server_address = server_address(&req);
232        let server_port = server_port(&req);
233        let network_protocol_version = network_protocol_version(&req);
234        let request_body_size = req
235            .body()
236            .and_then(|body| body.as_bytes())
237            .map(|bytes| bytes.len())
238            .unwrap_or(0);
239
240        let start = Instant::now();
241        let res = next.run(req, extensions).await;
242        let duration = start.elapsed();
243
244        let mut labels = vec![
245            (
246                self.label_names.http_request_method.to_string(),
247                http_request_method,
248            ),
249            (self.label_names.url_scheme.to_string(), url_scheme),
250            (
251                self.label_names.network_protocol_name.to_string(),
252                Cow::Borrowed("http"),
253            ),
254        ];
255
256        if let Some(server_address) = server_address {
257            labels.push((
258                self.label_names.server_address.to_string(),
259                Cow::Owned(server_address),
260            ));
261        }
262
263        if let Some(port) = server_port {
264            labels.push((
265                self.label_names.server_port.to_string(),
266                Cow::Owned(port.to_string()),
267            ));
268        }
269
270        if let Some(network_protocol_version) = network_protocol_version {
271            labels.push((
272                self.label_names.network_protocol_version.to_string(),
273                Cow::Borrowed(network_protocol_version),
274            ));
275        }
276
277        if let Some(status) = http_response_status(&res) {
278            labels.push((self.label_names.http_response_status.to_string(), status));
279        }
280
281        if let Some(error) = error_type(&res) {
282            labels.push((self.label_names.error_type.to_string(), error));
283        }
284
285        histogram!(HTTP_CLIENT_REQUEST_DURATION, &labels)
286            .record(duration.as_millis() as f64 / 1000.0);
287
288        histogram!(HTTP_CLIENT_REQUEST_BODY_SIZE, &labels).record(request_body_size as f64);
289
290        // NOTE: The response body size is not *guaranteed* to be in the content-length header, but
291        //       it will be added in nearly all modern HTTP implementations and waiting on the
292        //       response body would be a fairly large performance pentality to force on our users.
293        let response_body_size = res
294            .as_ref()
295            .ok()
296            .and_then(|res| res.content_length())
297            .unwrap_or(0);
298        histogram!(HTTP_CLIENT_RESPONSE_BODY_SIZE, &labels).record(response_body_size as f64);
299
300        res
301    }
302}
303
304fn http_request_method(req: &Request) -> Cow<'static, str> {
305    match req.method() {
306        &Method::GET => Cow::Borrowed("GET"),
307        &Method::POST => Cow::Borrowed("POST"),
308        &Method::PUT => Cow::Borrowed("PUT"),
309        &Method::DELETE => Cow::Borrowed("DELETE"),
310        &Method::HEAD => Cow::Borrowed("HEAD"),
311        &Method::OPTIONS => Cow::Borrowed("OPTIONS"),
312        &Method::CONNECT => Cow::Borrowed("CONNECT"),
313        &Method::PATCH => Cow::Borrowed("PATCH"),
314        &Method::TRACE => Cow::Borrowed("TRACE"),
315        method => Cow::Owned(method.as_str().to_string()),
316    }
317}
318
319fn url_scheme(req: &Request) -> Cow<'static, str> {
320    match req.url().scheme() {
321        "http" => Cow::Borrowed("http"),
322        "https" => Cow::Borrowed("https"),
323        s => Cow::Owned(s.to_string()),
324    }
325}
326
327fn server_address(req: &Request) -> Option<String> {
328    req.url().host().map(|h| h.to_string())
329}
330
331fn server_port(req: &Request) -> Option<u16> {
332    req.url().port_or_known_default()
333}
334
335fn http_response_status(res: &Result<Response>) -> Option<Cow<'static, str>> {
336    res.as_ref()
337        .map(|r| Cow::Owned(r.status().as_u16().to_string()))
338        .ok()
339}
340
341fn error_type(res: &Result<Response>) -> Option<Cow<'static, str>> {
342    Some(match res {
343        Ok(res) if res.status().is_client_error() || res.status().is_server_error() => {
344            Cow::Owned(res.status().as_str().to_string())
345        }
346        Err(Error::Middleware(err)) => Cow::Owned(format!("{err}")),
347        Err(Error::Reqwest(err)) => Cow::Owned(format!("{err}")),
348        _ => return None,
349    })
350}
351
352#[cfg(target_arch = "wasm32")]
353fn network_protocol_version(_req: &Request) -> Option<&'static str> {
354    None
355}
356
357#[cfg(not(target_arch = "wasm32"))]
358fn network_protocol_version(req: &Request) -> Option<&'static str> {
359    let version = req.version();
360
361    Some(match version {
362        http::Version::HTTP_09 => "0.9",
363        http::Version::HTTP_10 => "1.0",
364        http::Version::HTTP_11 => "1.1",
365        http::Version::HTTP_2 => "2",
366        http::Version::HTTP_3 => "3",
367        _ => return None,
368    })
369}