|
| 1 | +//! Prometheus `/metrics` endpoint as a tower middleware layer. |
| 2 | +//! |
| 3 | +//! Short-circuits `GET /metrics` ahead of jsonrpsee so scrapes never run |
| 4 | +//! through the JSON-RPC parser. Label cardinality is bounded by the |
| 5 | +//! enumerations in [`names`] — no user-controlled values become labels. |
| 6 | +
|
| 7 | +use std::task::{Context, Poll}; |
| 8 | + |
| 9 | +use bytes::Bytes; |
| 10 | +use futures::future::{ready, Either, Ready}; |
| 11 | +use http::{header, Method, Request, Response, StatusCode}; |
| 12 | +use http_body_util::Full; |
| 13 | +use jsonrpsee::server::HttpBody; |
| 14 | +use metrics_exporter_prometheus::{PrometheusBuilder, PrometheusHandle}; |
| 15 | +use tower::{Layer, Service}; |
| 16 | + |
| 17 | +#[cfg(test)] |
| 18 | +#[path = "metrics_test.rs"] |
| 19 | +mod metrics_test; |
| 20 | + |
| 21 | +/// Path served by [`MetricsLayer`]. |
| 22 | +pub const METRICS_PATH: &str = "/metrics"; |
| 23 | + |
| 24 | +/// Metric name constants. Kept here so `metrics!` invocations elsewhere link |
| 25 | +/// to a single definition instead of bare string literals. |
| 26 | +pub mod names { |
| 27 | + /// Build identity. Value is always 1; labels carry version + git_sha. |
| 28 | + pub const BUILD_INFO: &str = "prover_build_info"; |
| 29 | + /// Requests rejected because the concurrency semaphore was full. |
| 30 | + pub const CONCURRENCY_REJECTED_TOTAL: &str = "prover_concurrency_rejected_total"; |
| 31 | +} |
| 32 | + |
| 33 | +/// Initializes the global Prometheus exporter and emits the `build_info` |
| 34 | +/// gauge. Returns the handle used by [`MetricsLayer`] to render the scrape |
| 35 | +/// response. |
| 36 | +/// |
| 37 | +/// Should be called exactly once at startup. The handle is cheap to clone |
| 38 | +/// (it wraps an `Arc`). |
| 39 | +pub fn install_exporter(version: &str, git_sha: &str) -> anyhow::Result<PrometheusHandle> { |
| 40 | + let handle = PrometheusBuilder::new() |
| 41 | + .install_recorder() |
| 42 | + .map_err(|err| anyhow::anyhow!("failed to install prometheus recorder: {err}"))?; |
| 43 | + metrics::gauge!( |
| 44 | + names::BUILD_INFO, |
| 45 | + "version" => version.to_string(), |
| 46 | + "git_sha" => git_sha.to_string(), |
| 47 | + ) |
| 48 | + .set(1.0); |
| 49 | + // Pre-register the counter at 0 so it shows up in scrapes before the |
| 50 | + // first rejection — dashboards relying on `rate(...) > 0` need the |
| 51 | + // series to exist. |
| 52 | + metrics::counter!(names::CONCURRENCY_REJECTED_TOTAL).increment(0); |
| 53 | + Ok(handle) |
| 54 | +} |
| 55 | + |
| 56 | +/// tower [`Layer`] that intercepts `GET /metrics`. |
| 57 | +#[derive(Clone)] |
| 58 | +pub struct MetricsLayer { |
| 59 | + handle: PrometheusHandle, |
| 60 | +} |
| 61 | + |
| 62 | +impl MetricsLayer { |
| 63 | + pub fn new(handle: PrometheusHandle) -> Self { |
| 64 | + Self { handle } |
| 65 | + } |
| 66 | +} |
| 67 | + |
| 68 | +impl<S> Layer<S> for MetricsLayer { |
| 69 | + type Service = MetricsService<S>; |
| 70 | + |
| 71 | + fn layer(&self, inner: S) -> Self::Service { |
| 72 | + MetricsService { inner, handle: self.handle.clone() } |
| 73 | + } |
| 74 | +} |
| 75 | + |
| 76 | +#[derive(Clone)] |
| 77 | +pub struct MetricsService<S> { |
| 78 | + inner: S, |
| 79 | + handle: PrometheusHandle, |
| 80 | +} |
| 81 | + |
| 82 | +impl<S, ReqB> Service<Request<ReqB>> for MetricsService<S> |
| 83 | +where |
| 84 | + S: Service<Request<ReqB>, Response = Response<HttpBody>>, |
| 85 | +{ |
| 86 | + type Response = Response<HttpBody>; |
| 87 | + type Error = S::Error; |
| 88 | + type Future = Either<Ready<Result<Self::Response, Self::Error>>, S::Future>; |
| 89 | + |
| 90 | + fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> { |
| 91 | + self.inner.poll_ready(cx) |
| 92 | + } |
| 93 | + |
| 94 | + fn call(&mut self, request: Request<ReqB>) -> Self::Future { |
| 95 | + if request.method() == Method::GET && request.uri().path() == METRICS_PATH { |
| 96 | + let body = Bytes::from(self.handle.render()); |
| 97 | + let response = Response::builder() |
| 98 | + .status(StatusCode::OK) |
| 99 | + .header(header::CONTENT_TYPE, "text/plain; version=0.0.4") |
| 100 | + .body(HttpBody::new(Full::new(body))) |
| 101 | + .expect("response build with a string body is infallible"); |
| 102 | + return Either::Left(ready(Ok(response))); |
| 103 | + } |
| 104 | + Either::Right(self.inner.call(request)) |
| 105 | + } |
| 106 | +} |
0 commit comments