pub mod middleware;
use axum::http::header::CONTENT_TYPE;
use metrics::{describe_counter, describe_histogram};
use metrics_exporter_prometheus::{
BuildError, Matcher, PrometheusBuilder, PrometheusHandle, PrometheusRecorder,
};
pub const METRIC_HTTP_REQUESTS_TOTAL: &str = "docspec_http_requests_total";
pub const METRIC_HTTP_REQUEST_DURATION_SECONDS: &str = "docspec_http_request_duration_seconds";
pub const METRIC_HTTP_REQUEST_BODY_BYTES: &str = "docspec_http_request_body_bytes";
pub const METRIC_CONVERSIONS_TOTAL: &str = "docspec_conversions_total";
pub const METRIC_CONVERSION_DURATION_SECONDS: &str = "docspec_conversion_duration_seconds";
pub const METRIC_CONVERSION_OUTPUT_BYTES: &str = "docspec_conversion_output_bytes";
pub const LABEL_METHOD: &str = "method";
pub const LABEL_PATH: &str = "path";
pub const LABEL_STATUS: &str = "status";
pub const LABEL_RESULT: &str = "result";
pub const LABEL_ERROR_CLASS: &str = "error_class";
pub const LABEL_INPUT_MIME_TYPE: &str = "input_mime_type";
pub const LABEL_OUTPUT_MIME_TYPE: &str = "output_mime_type";
pub const PATH_UNKNOWN: &str = "unknown";
pub const RESULT_SUCCESS: &str = "success";
pub const RESULT_CLIENT_ERROR: &str = "client_error";
pub const RESULT_SERVER_ERROR: &str = "server_error";
pub const ERROR_CLASS_NONE: &str = "none";
pub const INPUT_MIME_MARKDOWN: &str = "text/markdown";
pub const INPUT_MIME_HTML: &str = "text/html";
pub const INPUT_MIME_UNSUPPORTED: &str = "unsupported";
pub const INPUT_MIME_NONE: &str = "none";
pub const OUTPUT_MIME_BLOCKNOTE: &str = "application/vnd.docspec.blocknote+json";
pub const OUTPUT_MIME_OXA: &str = "application/vnd.oxa+json";
pub const OUTPUT_MIME_NONE: &str = "none";
pub const HTTP_LATENCY_BUCKETS: [f64; 11] = [
0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0,
];
pub const HTTP_BODY_SIZE_BUCKETS: [f64; 12] = [
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,
204_800.0,
];
pub const CONVERSION_DURATION_BUCKETS: [f64; 11] = HTTP_LATENCY_BUCKETS;
pub const CONVERSION_OUTPUT_BYTES_BUCKETS: [f64; 12] = HTTP_BODY_SIZE_BUCKETS;
fn configure_buckets(builder: PrometheusBuilder) -> Result<PrometheusBuilder, BuildError> {
builder
.set_buckets_for_metric(
Matcher::Full(METRIC_HTTP_REQUEST_DURATION_SECONDS.to_owned()),
&HTTP_LATENCY_BUCKETS,
)?
.set_buckets_for_metric(
Matcher::Full(METRIC_HTTP_REQUEST_BODY_BYTES.to_owned()),
&HTTP_BODY_SIZE_BUCKETS,
)?
.set_buckets_for_metric(
Matcher::Full(METRIC_CONVERSION_DURATION_SECONDS.to_owned()),
&CONVERSION_DURATION_BUCKETS,
)?
.set_buckets_for_metric(
Matcher::Full(METRIC_CONVERSION_OUTPUT_BYTES.to_owned()),
&CONVERSION_OUTPUT_BYTES_BUCKETS,
)
}
#[inline]
pub fn build_recorder() -> Result<(PrometheusRecorder, PrometheusHandle), BuildError> {
let recorder = configure_buckets(PrometheusBuilder::new())?.build_recorder();
let handle = recorder.handle();
Ok((recorder, handle))
}
#[inline]
pub fn install_global() -> Result<PrometheusHandle, BuildError> {
let handle = configure_buckets(PrometheusBuilder::new())?.install_recorder()?;
describe_counter!(METRIC_HTTP_REQUESTS_TOTAL, "Total HTTP requests received.");
describe_histogram!(
METRIC_HTTP_REQUEST_DURATION_SECONDS,
"HTTP request latency in seconds."
);
describe_histogram!(
METRIC_HTTP_REQUEST_BODY_BYTES,
"HTTP request body size in bytes, labeled by input MIME type."
);
describe_counter!(
METRIC_CONVERSIONS_TOTAL,
"Total document conversions, labeled by result, error class, and input/output MIME type."
);
describe_histogram!(
METRIC_CONVERSION_DURATION_SECONDS,
"Document conversion duration in seconds, labeled by result and input/output MIME type."
);
describe_histogram!(
METRIC_CONVERSION_OUTPUT_BYTES,
"Document conversion output size in bytes (recorded only on successful conversions), labeled by input/output MIME type."
);
Ok(handle)
}
#[inline]
pub fn metrics_handler(
handle: PrometheusHandle,
) -> impl Fn() -> core::future::Ready<axum::response::Response> + Clone + Send + 'static {
move || {
let output = handle.render();
core::future::ready(
axum::http::Response::builder()
.header(CONTENT_TYPE, "text/plain; version=0.0.4; charset=utf-8")
.body(axum::body::Body::from(output))
.unwrap_or_else(|_| {
let mut response = axum::response::Response::new(axum::body::Body::from(
"failed to render metrics response",
));
*response.status_mut() = axum::http::StatusCode::INTERNAL_SERVER_ERROR;
response
}),
)
}
}
#[cfg(test)]
mod handler_tests {
#![allow(
clippy::tests_outside_test_module,
clippy::unwrap_used,
clippy::expect_used
)]
use super::*;
use axum::body::to_bytes;
#[tokio::test]
async fn metrics_handler_returns_200() {
let (recorder, handle) = build_recorder().expect("test recorder builds");
let result = metrics::with_local_recorder(&recorder, || {
let handler = metrics_handler(handle);
handler()
});
let response = result.await;
assert_eq!(response.status(), axum::http::StatusCode::OK);
}
#[tokio::test]
async fn metrics_handler_content_type_is_prometheus_text() {
let (recorder, handle) = build_recorder().expect("test recorder builds");
let result = metrics::with_local_recorder(&recorder, || {
let handler = metrics_handler(handle);
handler()
});
let response = result.await;
let content_type = response
.headers()
.get(axum::http::header::CONTENT_TYPE)
.expect("content-type header present")
.to_str()
.expect("content-type is valid str");
assert_eq!(content_type, "text/plain; version=0.0.4; charset=utf-8");
}
#[tokio::test]
async fn metrics_handler_body_is_valid_utf8() {
let (recorder, handle) = build_recorder().expect("test recorder builds");
let result = metrics::with_local_recorder(&recorder, || {
let handler = metrics_handler(handle);
handler()
});
let response = result.await;
let body_bytes = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body readable");
let _body_str = String::from_utf8(body_bytes.to_vec()).expect("body is valid UTF-8");
}
}