Skip to main content

docspec_http/metrics/
mod.rs

1//! Prometheus metrics for the `docspec-http` server.
2//!
3//! Each pod exposes its own `/metrics` endpoint. The Prometheus server
4//! scrapes each pod independently — no aggregation happens in-process.
5//!
6//! # Design
7//!
8//! This module owns all metric-name and label constants. Middleware (see
9//! [`middleware`]) records values; this module only declares and describes
10//! them. Call [`install_global`] once at startup before accepting requests,
11//! then mount the closure returned by [`metrics_handler`] at `/metrics`.
12
13pub 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
21// ─── Metric-name constants ─────────────────────────────────────────────────
22
23/// Counter: total number of HTTP requests received.
24pub const METRIC_HTTP_REQUESTS_TOTAL: &str = "docspec_http_requests_total";
25
26/// Histogram: HTTP request latency in seconds.
27pub const METRIC_HTTP_REQUEST_DURATION_SECONDS: &str = "docspec_http_request_duration_seconds";
28
29/// Histogram: HTTP request body size in bytes.
30pub const METRIC_HTTP_REQUEST_BODY_BYTES: &str = "docspec_http_request_body_bytes";
31
32/// Counter: total number of document conversions.
33pub const METRIC_CONVERSIONS_TOTAL: &str = "docspec_conversions_total";
34
35/// Histogram: document conversion duration in seconds.
36pub const METRIC_CONVERSION_DURATION_SECONDS: &str = "docspec_conversion_duration_seconds";
37
38/// Histogram: document conversion output size in bytes.
39pub const METRIC_CONVERSION_OUTPUT_BYTES: &str = "docspec_conversion_output_bytes";
40
41// ─── Label key constants ───────────────────────────────────────────────────
42
43/// Label key for the HTTP request method (GET, POST, …).
44pub const LABEL_METHOD: &str = "method";
45
46/// Label key for the matched route path pattern.
47pub const LABEL_PATH: &str = "path";
48
49/// Label key for the HTTP response status code.
50pub const LABEL_STATUS: &str = "status";
51
52/// Label key for the conversion outcome category.
53pub const LABEL_RESULT: &str = "result";
54
55/// Label key for the error class when a conversion fails.
56pub const LABEL_ERROR_CLASS: &str = "error_class";
57
58/// Label key for the input MIME type of the conversion request.
59pub const LABEL_INPUT_MIME_TYPE: &str = "input_mime_type";
60
61/// Label key for the output MIME type produced by the conversion.
62pub const LABEL_OUTPUT_MIME_TYPE: &str = "output_mime_type";
63
64// ─── Label value constants ─────────────────────────────────────────────────
65
66/// Value for [`LABEL_PATH`] when no route was matched by the router.
67pub const PATH_UNKNOWN: &str = "unknown";
68
69/// Value for [`LABEL_RESULT`] when a conversion succeeds.
70pub const RESULT_SUCCESS: &str = "success";
71
72/// Value for [`LABEL_RESULT`] when a conversion fails due to a client error.
73pub const RESULT_CLIENT_ERROR: &str = "client_error";
74
75/// Value for [`LABEL_RESULT`] when a conversion fails due to a server error.
76pub const RESULT_SERVER_ERROR: &str = "server_error";
77
78/// Value for [`LABEL_ERROR_CLASS`] when no error occurred.
79pub const ERROR_CLASS_NONE: &str = "none";
80
81/// Value for [`LABEL_INPUT_MIME_TYPE`] when the request was text/markdown.
82pub const INPUT_MIME_MARKDOWN: &str = "text/markdown";
83
84/// Value for [`LABEL_INPUT_MIME_TYPE`] when the request was text/html.
85pub const INPUT_MIME_HTML: &str = "text/html";
86
87/// Value for [`LABEL_INPUT_MIME_TYPE`] when the Content-Type header was present but not a supported reader format.
88pub const INPUT_MIME_UNSUPPORTED: &str = "unsupported";
89
90/// Value for [`LABEL_INPUT_MIME_TYPE`] when the Content-Type header was absent.
91pub const INPUT_MIME_NONE: &str = "none";
92
93/// Value for [`LABEL_OUTPUT_MIME_TYPE`] when the conversion produced `BlockNote` JSON.
94pub const OUTPUT_MIME_BLOCKNOTE: &str = "application/vnd.docspec.blocknote+json";
95
96/// Value for [`LABEL_OUTPUT_MIME_TYPE`] when the conversion produced HTML.
97pub const OUTPUT_MIME_HTML: &str = "text/html";
98
99/// Value for [`LABEL_OUTPUT_MIME_TYPE`] when the conversion produced `oxa.dev` JSON.
100pub const OUTPUT_MIME_OXA: &str = "application/vnd.oxa+json";
101
102/// Value for [`LABEL_OUTPUT_MIME_TYPE`] when no output was produced (any error path).
103pub const OUTPUT_MIME_NONE: &str = "none";
104
105// ─── Histogram bucket arrays ───────────────────────────────────────────────
106
107/// Latency histogram buckets for HTTP request duration, in seconds.
108pub const HTTP_LATENCY_BUCKETS: [f64; 11] = [
109    0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0,
110];
111
112/// Body-size histogram buckets for HTTP request bodies, in bytes.
113pub const HTTP_BODY_SIZE_BUCKETS: [f64; 12] = [
114    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,
115    204_800.0,
116];
117
118/// Latency histogram buckets for document conversion duration, in seconds.
119///
120/// Shares the same breakpoints as [`HTTP_LATENCY_BUCKETS`].
121pub const CONVERSION_DURATION_BUCKETS: [f64; 11] = HTTP_LATENCY_BUCKETS;
122
123/// Size histogram buckets for conversion output bytes.
124///
125/// Shares the same breakpoints as [`HTTP_BODY_SIZE_BUCKETS`] for symmetry
126/// between request body size and conversion output size in dashboards.
127pub const CONVERSION_OUTPUT_BYTES_BUCKETS: [f64; 12] = HTTP_BODY_SIZE_BUCKETS;
128
129// ─── Recorder builder ──────────────────────────────────────────────────────
130
131fn configure_buckets(builder: PrometheusBuilder) -> Result<PrometheusBuilder, BuildError> {
132    builder
133        .set_buckets_for_metric(
134            Matcher::Full(METRIC_HTTP_REQUEST_DURATION_SECONDS.to_owned()),
135            &HTTP_LATENCY_BUCKETS,
136        )?
137        .set_buckets_for_metric(
138            Matcher::Full(METRIC_HTTP_REQUEST_BODY_BYTES.to_owned()),
139            &HTTP_BODY_SIZE_BUCKETS,
140        )?
141        .set_buckets_for_metric(
142            Matcher::Full(METRIC_CONVERSION_DURATION_SECONDS.to_owned()),
143            &CONVERSION_DURATION_BUCKETS,
144        )?
145        .set_buckets_for_metric(
146            Matcher::Full(METRIC_CONVERSION_OUTPUT_BYTES.to_owned()),
147            &CONVERSION_OUTPUT_BYTES_BUCKETS,
148        )
149}
150
151/// Builds a configured [`PrometheusRecorder`] and its [`PrometheusHandle`]
152/// **without** installing it globally.
153///
154/// Use this in tests together with [`metrics::with_local_recorder`] to keep
155/// test runs isolated from one another and from the global recorder.
156///
157/// # Errors
158///
159/// Returns [`BuildError`] when bucket values are empty or otherwise invalid.
160#[inline]
161pub fn build_recorder() -> Result<(PrometheusRecorder, PrometheusHandle), BuildError> {
162    let recorder = configure_buckets(PrometheusBuilder::new())?.build_recorder();
163    let handle = recorder.handle();
164    Ok((recorder, handle))
165}
166
167/// Installs the Prometheus recorder globally and registers HELP text for every
168/// metric.
169///
170/// Call **once** at server startup before accepting requests. Returns the
171/// [`PrometheusHandle`] needed to scrape the `/metrics` endpoint.
172///
173/// # Errors
174///
175/// Returns [`BuildError`] when bucket configuration or recorder installation
176/// fails (e.g., a global recorder is already installed).
177#[inline]
178pub fn install_global() -> Result<PrometheusHandle, BuildError> {
179    let handle = configure_buckets(PrometheusBuilder::new())?.install_recorder()?;
180
181    describe_counter!(METRIC_HTTP_REQUESTS_TOTAL, "Total HTTP requests received.");
182    describe_histogram!(
183        METRIC_HTTP_REQUEST_DURATION_SECONDS,
184        "HTTP request latency in seconds."
185    );
186    describe_histogram!(
187        METRIC_HTTP_REQUEST_BODY_BYTES,
188        "HTTP request body size in bytes, labeled by input MIME type."
189    );
190    describe_counter!(
191        METRIC_CONVERSIONS_TOTAL,
192        "Total document conversions, labeled by result, error class, and input/output MIME type."
193    );
194    describe_histogram!(
195        METRIC_CONVERSION_DURATION_SECONDS,
196        "Document conversion duration in seconds, labeled by result and input/output MIME type."
197    );
198    describe_histogram!(
199        METRIC_CONVERSION_OUTPUT_BYTES,
200        "Document conversion output size in bytes (recorded only on successful conversions), labeled by input/output MIME type."
201    );
202
203    Ok(handle)
204}
205
206// ─── Metrics endpoint handler ──────────────────────────────────────────────
207
208/// Returns a clone-safe handler closure that renders Prometheus metrics as an
209/// HTTP response with the correct Content-Type.
210///
211/// Mount the returned closure at `/metrics`. The response Content-Type is
212/// `text/plain; version=0.0.4; charset=utf-8` as required by the Prometheus
213/// exposition format specification.
214#[inline]
215pub fn metrics_handler(
216    handle: PrometheusHandle,
217) -> impl Fn() -> core::future::Ready<axum::response::Response> + Clone + Send + 'static {
218    move || {
219        let output = handle.render();
220        core::future::ready(
221            axum::http::Response::builder()
222                .header(CONTENT_TYPE, "text/plain; version=0.0.4; charset=utf-8")
223                .body(axum::body::Body::from(output))
224                .unwrap_or_else(|_| {
225                    let mut response = axum::response::Response::new(axum::body::Body::from(
226                        "failed to render metrics response",
227                    ));
228                    *response.status_mut() = axum::http::StatusCode::INTERNAL_SERVER_ERROR;
229                    response
230                }),
231        )
232    }
233}
234
235#[cfg(test)]
236mod handler_tests {
237    #![allow(
238        clippy::tests_outside_test_module,
239        clippy::unwrap_used,
240        clippy::expect_used
241    )]
242
243    use super::*;
244    use axum::body::to_bytes;
245
246    #[tokio::test]
247    async fn metrics_handler_returns_200() {
248        let (recorder, handle) = build_recorder().expect("test recorder builds");
249        let result = metrics::with_local_recorder(&recorder, || {
250            let handler = metrics_handler(handle);
251            handler()
252        });
253        let response = result.await;
254        assert_eq!(response.status(), axum::http::StatusCode::OK);
255    }
256
257    #[tokio::test]
258    async fn metrics_handler_content_type_is_prometheus_text() {
259        let (recorder, handle) = build_recorder().expect("test recorder builds");
260        let result = metrics::with_local_recorder(&recorder, || {
261            let handler = metrics_handler(handle);
262            handler()
263        });
264        let response = result.await;
265        let content_type = response
266            .headers()
267            .get(axum::http::header::CONTENT_TYPE)
268            .expect("content-type header present")
269            .to_str()
270            .expect("content-type is valid str");
271        assert_eq!(content_type, "text/plain; version=0.0.4; charset=utf-8");
272    }
273
274    #[tokio::test]
275    async fn metrics_handler_body_is_valid_utf8() {
276        let (recorder, handle) = build_recorder().expect("test recorder builds");
277        let result = metrics::with_local_recorder(&recorder, || {
278            let handler = metrics_handler(handle);
279            handler()
280        });
281        let response = result.await;
282        let body_bytes = to_bytes(response.into_body(), usize::MAX)
283            .await
284            .expect("body readable");
285        let _body_str = String::from_utf8(body_bytes.to_vec()).expect("body is valid UTF-8");
286    }
287}