docspec_http/metrics/
mod.rs1pub mod middleware;
14
15use axum::http::header::CONTENT_TYPE;
16use metrics::{describe_counter, describe_histogram};
17use metrics_exporter_prometheus::{
18 BuildError, Matcher, PrometheusBuilder, PrometheusHandle, PrometheusRecorder,
19};
20
21pub const METRIC_HTTP_REQUESTS_TOTAL: &str = "docspec_http_requests_total";
25
26pub const METRIC_HTTP_REQUEST_DURATION_SECONDS: &str = "docspec_http_request_duration_seconds";
28
29pub const METRIC_HTTP_REQUEST_BODY_BYTES: &str = "docspec_http_request_body_bytes";
31
32pub const METRIC_CONVERSIONS_TOTAL: &str = "docspec_conversions_total";
34
35pub const METRIC_CONVERSION_DURATION_SECONDS: &str = "docspec_conversion_duration_seconds";
37
38pub const METRIC_CONVERSION_OUTPUT_BYTES: &str = "docspec_conversion_output_bytes";
40
41pub const LABEL_METHOD: &str = "method";
45
46pub const LABEL_PATH: &str = "path";
48
49pub const LABEL_STATUS: &str = "status";
51
52pub const LABEL_RESULT: &str = "result";
54
55pub const LABEL_ERROR_CLASS: &str = "error_class";
57
58pub const LABEL_INPUT_MIME_TYPE: &str = "input_mime_type";
60
61pub const LABEL_OUTPUT_MIME_TYPE: &str = "output_mime_type";
63
64pub const PATH_UNKNOWN: &str = "unknown";
68
69pub const RESULT_SUCCESS: &str = "success";
71
72pub const RESULT_CLIENT_ERROR: &str = "client_error";
74
75pub const RESULT_SERVER_ERROR: &str = "server_error";
77
78pub const ERROR_CLASS_NONE: &str = "none";
80
81pub const INPUT_MIME_MARKDOWN: &str = "text/markdown";
83
84pub const INPUT_MIME_HTML: &str = "text/html";
86
87pub const INPUT_MIME_DOCX: &str =
89 "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
90
91pub const INPUT_MIME_UNSUPPORTED: &str = "unsupported";
93
94pub const INPUT_MIME_NONE: &str = "none";
96
97pub const OUTPUT_MIME_BLOCKNOTE: &str = "application/vnd.docspec.blocknote+json";
99
100pub const OUTPUT_MIME_HTML: &str = "text/html";
102
103pub const OUTPUT_MIME_OXA: &str = "application/vnd.oxa+json";
105
106pub const OUTPUT_MIME_PANDOC_NATIVE: &str = "application/vnd.pandoc.native";
108
109pub const OUTPUT_MIME_NONE: &str = "none";
111
112pub const HTTP_LATENCY_BUCKETS: [f64; 11] = [
116 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0,
117];
118
119pub const HTTP_BODY_SIZE_BUCKETS: [f64; 12] = [
121 100.0, 200.0, 400.0, 800.0, 1_600.0, 3_200.0, 6_400.0, 12_800.0, 25_600.0, 51_200.0, 102_400.0,
122 204_800.0,
123];
124
125pub const CONVERSION_DURATION_BUCKETS: [f64; 11] = HTTP_LATENCY_BUCKETS;
129
130pub const CONVERSION_OUTPUT_BYTES_BUCKETS: [f64; 12] = HTTP_BODY_SIZE_BUCKETS;
135
136fn configure_buckets(builder: PrometheusBuilder) -> Result<PrometheusBuilder, BuildError> {
139 builder
140 .set_buckets_for_metric(
141 Matcher::Full(METRIC_HTTP_REQUEST_DURATION_SECONDS.to_owned()),
142 &HTTP_LATENCY_BUCKETS,
143 )?
144 .set_buckets_for_metric(
145 Matcher::Full(METRIC_HTTP_REQUEST_BODY_BYTES.to_owned()),
146 &HTTP_BODY_SIZE_BUCKETS,
147 )?
148 .set_buckets_for_metric(
149 Matcher::Full(METRIC_CONVERSION_DURATION_SECONDS.to_owned()),
150 &CONVERSION_DURATION_BUCKETS,
151 )?
152 .set_buckets_for_metric(
153 Matcher::Full(METRIC_CONVERSION_OUTPUT_BYTES.to_owned()),
154 &CONVERSION_OUTPUT_BYTES_BUCKETS,
155 )
156}
157
158#[inline]
168pub fn build_recorder() -> Result<(PrometheusRecorder, PrometheusHandle), BuildError> {
169 let recorder = configure_buckets(PrometheusBuilder::new())?.build_recorder();
170 let handle = recorder.handle();
171 Ok((recorder, handle))
172}
173
174#[inline]
185pub fn install_global() -> Result<PrometheusHandle, BuildError> {
186 let handle = configure_buckets(PrometheusBuilder::new())?.install_recorder()?;
187
188 describe_counter!(METRIC_HTTP_REQUESTS_TOTAL, "Total HTTP requests received.");
189 describe_histogram!(
190 METRIC_HTTP_REQUEST_DURATION_SECONDS,
191 "HTTP request latency in seconds."
192 );
193 describe_histogram!(
194 METRIC_HTTP_REQUEST_BODY_BYTES,
195 "HTTP request body size in bytes, labeled by input MIME type."
196 );
197 describe_counter!(
198 METRIC_CONVERSIONS_TOTAL,
199 "Total document conversions, labeled by result, error class, and input/output MIME type."
200 );
201 describe_histogram!(
202 METRIC_CONVERSION_DURATION_SECONDS,
203 "Document conversion duration in seconds, labeled by result and input/output MIME type."
204 );
205 describe_histogram!(
206 METRIC_CONVERSION_OUTPUT_BYTES,
207 "Document conversion output size in bytes (recorded only on successful conversions), labeled by input/output MIME type."
208 );
209
210 Ok(handle)
211}
212
213#[inline]
222pub fn metrics_handler(
223 handle: PrometheusHandle,
224) -> impl Fn() -> core::future::Ready<axum::response::Response> + Clone + Send + 'static {
225 move || {
226 let output = handle.render();
227 core::future::ready(
228 axum::http::Response::builder()
229 .header(CONTENT_TYPE, "text/plain; version=0.0.4; charset=utf-8")
230 .body(axum::body::Body::from(output))
231 .unwrap_or_else(|_| {
232 let mut response = axum::response::Response::new(axum::body::Body::from(
233 "failed to render metrics response",
234 ));
235 *response.status_mut() = axum::http::StatusCode::INTERNAL_SERVER_ERROR;
236 response
237 }),
238 )
239 }
240}
241
242#[cfg(test)]
243mod handler_tests {
244 #![allow(
245 clippy::tests_outside_test_module,
246 clippy::unwrap_used,
247 clippy::expect_used
248 )]
249
250 use super::*;
251 use axum::body::to_bytes;
252
253 #[tokio::test]
254 async fn metrics_handler_returns_200() {
255 let (recorder, handle) = build_recorder().expect("test recorder builds");
256 let result = metrics::with_local_recorder(&recorder, || {
257 let handler = metrics_handler(handle);
258 handler()
259 });
260 let response = result.await;
261 assert_eq!(response.status(), axum::http::StatusCode::OK);
262 }
263
264 #[tokio::test]
265 async fn metrics_handler_content_type_is_prometheus_text() {
266 let (recorder, handle) = build_recorder().expect("test recorder builds");
267 let result = metrics::with_local_recorder(&recorder, || {
268 let handler = metrics_handler(handle);
269 handler()
270 });
271 let response = result.await;
272 let content_type = response
273 .headers()
274 .get(axum::http::header::CONTENT_TYPE)
275 .expect("content-type header present")
276 .to_str()
277 .expect("content-type is valid str");
278 assert_eq!(content_type, "text/plain; version=0.0.4; charset=utf-8");
279 }
280
281 #[tokio::test]
282 async fn metrics_handler_body_is_valid_utf8() {
283 let (recorder, handle) = build_recorder().expect("test recorder builds");
284 let result = metrics::with_local_recorder(&recorder, || {
285 let handler = metrics_handler(handle);
286 handler()
287 });
288 let response = result.await;
289 let body_bytes = to_bytes(response.into_body(), usize::MAX)
290 .await
291 .expect("body readable");
292 let _body_str = String::from_utf8(body_bytes.to_vec()).expect("body is valid UTF-8");
293 }
294}