kerniq_observability_prometheus/
lib.rs1#![warn(missing_docs)]
33#![cfg_attr(docsrs, feature(doc_cfg))]
34
35use std::future::Future;
36use std::net::SocketAddr;
37
38use axum::Router;
39use axum::http::{StatusCode, header};
40use axum::response::{IntoResponse, Response};
41use axum::routing::get;
42use prometheus::{Encoder, TextEncoder};
43
44pub use opentelemetry_prometheus::PrometheusExporter as PrometheusReader;
45
46#[derive(Debug, thiserror::Error)]
48pub enum Error {
49 #[error("failed to build the Prometheus exporter: {0}")]
51 Build(String),
52 #[error("failed to encode metrics: {0}")]
54 Encode(#[from] prometheus::Error),
55 #[error("metrics listener error: {0}")]
57 Io(#[from] std::io::Error),
58}
59
60#[derive(Debug, Clone)]
62#[must_use]
63pub struct PrometheusConfig {
64 path: String,
65 listen: Option<SocketAddr>,
66 scope_labels: bool,
67 target_info: bool,
68 namespace: Option<String>,
69 resource_labels: Vec<String>,
70}
71
72impl Default for PrometheusConfig {
73 fn default() -> Self {
74 Self {
75 path: "/metrics".to_owned(),
76 listen: None,
77 scope_labels: false,
78 target_info: true,
79 namespace: None,
80 resource_labels: vec!["service.name".to_owned()],
81 }
82 }
83}
84
85impl PrometheusConfig {
86 pub fn new() -> Self {
88 Self::default()
89 }
90
91 pub fn path(mut self, path: impl Into<String>) -> Self {
97 let path = path.into();
98 assert!(path.starts_with('/'), "metrics path must start with '/'");
99 self.path = path;
100 self
101 }
102
103 pub fn listen(mut self, address: SocketAddr) -> Self {
108 self.listen = Some(address);
109 self
110 }
111
112 pub fn scope_labels(mut self, enabled: bool) -> Self {
116 self.scope_labels = enabled;
117 self
118 }
119
120 pub fn target_info(mut self, enabled: bool) -> Self {
123 self.target_info = enabled;
124 self
125 }
126
127 pub fn resource_labels<I, K>(mut self, keys: I) -> Self
133 where
134 I: IntoIterator<Item = K>,
135 K: Into<String>,
136 {
137 self.resource_labels = keys.into_iter().map(Into::into).collect();
138 self
139 }
140
141 pub fn namespace(mut self, namespace: impl Into<String>) -> Self {
143 self.namespace = Some(namespace.into());
144 self
145 }
146
147 pub fn scrape_path(&self) -> &str {
149 &self.path
150 }
151
152 pub fn listen_address(&self) -> Option<SocketAddr> {
154 self.listen
155 }
156}
157
158#[derive(Debug, Clone)]
161pub struct PrometheusExporter {
162 registry: prometheus::Registry,
163 config: PrometheusConfig,
164}
165
166impl PrometheusExporter {
167 pub fn build(config: PrometheusConfig) -> Result<(PrometheusReader, Self), Error> {
170 let registry = prometheus::Registry::new();
171 let mut builder = opentelemetry_prometheus::exporter()
172 .with_registry(registry.clone())
173 .scope_info_enabled(config.scope_labels);
174 if !config.target_info {
175 builder = builder.without_target_info();
176 }
177 if !config.resource_labels.is_empty() {
178 let keys: std::collections::HashSet<opentelemetry::Key> = config
179 .resource_labels
180 .iter()
181 .map(|k| opentelemetry::Key::new(k.clone()))
182 .collect();
183 builder = builder.with_resource_selector(keys);
184 }
185 if let Some(ns) = &config.namespace {
186 builder = builder.with_namespace(ns.clone());
187 }
188 let reader = builder.build().map_err(|e| Error::Build(e.to_string()))?;
189 Ok((reader, Self { registry, config }))
190 }
191
192 pub fn config(&self) -> &PrometheusConfig {
194 &self.config
195 }
196
197 pub fn render(&self) -> Result<String, Error> {
203 let families = self.registry.gather();
204 let mut buffer = Vec::with_capacity(16 * 1024);
205 TextEncoder::new().encode(&families, &mut buffer)?;
206 Ok(String::from_utf8_lossy(&buffer).into_owned())
207 }
208
209 pub async fn render_async(&self) -> Result<String, Error> {
211 let this = self.clone();
212 match tokio::task::spawn_blocking(move || this.render()).await {
213 Ok(result) => result,
214 Err(join) => Err(Error::Io(std::io::Error::other(join))),
215 }
216 }
217
218 pub fn router<S>(&self) -> Router<S>
222 where
223 S: Clone + Send + Sync + 'static,
224 {
225 let exporter = self.clone();
226 Router::new().route(
227 &self.config.path,
228 get(move || {
229 let exporter = exporter.clone();
230 async move { exporter.scrape().await }
231 }),
232 )
233 }
234
235 async fn scrape(&self) -> Response {
236 match self.render_async().await {
237 Ok(body) => (
238 [(
239 header::CONTENT_TYPE,
240 TextEncoder::new().format_type().to_owned(),
241 )],
242 body,
243 )
244 .into_response(),
245 Err(err) => {
246 tracing::error!(error = %err, "failed to render Prometheus metrics");
247 StatusCode::INTERNAL_SERVER_ERROR.into_response()
248 }
249 }
250 }
251
252 pub async fn serve(
256 &self,
257 listener: tokio::net::TcpListener,
258 shutdown: impl Future<Output = ()> + Send + 'static,
259 ) -> Result<(), Error> {
260 let address = listener.local_addr()?;
261 tracing::info!(%address, path = %self.config.path, "Prometheus metrics listener started");
262 axum::serve(listener, self.router::<()>())
263 .with_graceful_shutdown(shutdown)
264 .await?;
265 Ok(())
266 }
267}