1use crate::{
2 chain::ChainSpecInfo,
3 hooks::{Hook, Hooks},
4 process::register_process_metrics,
5 recorder::install_prometheus_recorder,
6 storage::StorageSettingsInfo,
7 version::VersionInfo,
8};
9use bytes::Bytes;
10use eyre::WrapErr;
11use http::{header::CONTENT_TYPE, HeaderValue, Request, Response, StatusCode};
12use http_body_util::Full;
13use metrics::describe_gauge;
14use metrics_process::Collector;
15use reqwest::Client;
16use reth_metrics::metrics::Unit;
17use reth_tasks::TaskExecutor;
18use std::{convert::Infallible, net::SocketAddr, path::PathBuf, sync::Arc, time::Duration};
19
20#[derive(Debug)]
22pub struct MetricServerConfig {
23 listen_addr: SocketAddr,
24 version_info: VersionInfo,
25 chain_spec_info: ChainSpecInfo,
26 storage_settings_info: Option<StorageSettingsInfo>,
27 task_executor: TaskExecutor,
28 hooks: Hooks,
29 push_gateway_url: Option<String>,
30 push_gateway_interval: Duration,
31 pprof_dump_dir: PathBuf,
32}
33
34impl MetricServerConfig {
35 pub const fn new(
37 listen_addr: SocketAddr,
38 version_info: VersionInfo,
39 chain_spec_info: ChainSpecInfo,
40 task_executor: TaskExecutor,
41 hooks: Hooks,
42 pprof_dump_dir: PathBuf,
43 ) -> Self {
44 Self {
45 listen_addr,
46 hooks,
47 task_executor,
48 version_info,
49 chain_spec_info,
50 storage_settings_info: None,
51 push_gateway_url: None,
52 push_gateway_interval: Duration::from_secs(5),
53 pprof_dump_dir,
54 }
55 }
56
57 pub fn with_storage_settings_info(mut self, info: StorageSettingsInfo) -> Self {
59 self.storage_settings_info = Some(info);
60 self
61 }
62
63 pub fn with_push_gateway(mut self, url: Option<String>, interval: Duration) -> Self {
65 self.push_gateway_url = url;
66 self.push_gateway_interval = interval;
67 self
68 }
69}
70
71#[derive(Debug)]
73pub struct MetricServer {
74 config: MetricServerConfig,
75}
76
77impl MetricServer {
78 pub const fn new(config: MetricServerConfig) -> Self {
80 Self { config }
81 }
82
83 pub async fn serve(&self) -> eyre::Result<()> {
85 let MetricServerConfig {
86 listen_addr,
87 hooks,
88 task_executor,
89 version_info,
90 chain_spec_info,
91 storage_settings_info,
92 push_gateway_url,
93 push_gateway_interval,
94 pprof_dump_dir,
95 } = &self.config;
96
97 let hooks_for_endpoint = hooks.clone();
98 self.start_endpoint(
99 *listen_addr,
100 Arc::new(move || hooks_for_endpoint.iter().for_each(|hook| hook())),
101 task_executor.clone(),
102 pprof_dump_dir.clone(),
103 )
104 .await
105 .wrap_err_with(|| format!("Could not start Prometheus endpoint at {listen_addr}"))?;
106
107 if let Some(url) = push_gateway_url {
109 self.start_push_gateway_task(
110 url.clone(),
111 *push_gateway_interval,
112 hooks.clone(),
113 task_executor.clone(),
114 )?;
115 }
116
117 describe_db_metrics();
119 describe_static_file_metrics();
120 describe_rocksdb_metrics();
121 Collector::default().describe();
122 describe_memory_stats();
123 describe_io_stats();
124
125 version_info.register_version_metrics();
126 chain_spec_info.register_chain_spec_metrics();
127 if let Some(storage_settings_info) = storage_settings_info {
128 storage_settings_info.register_storage_settings_metrics();
129 }
130 register_process_metrics();
131
132 Ok(())
133 }
134
135 async fn start_endpoint<F: Hook + 'static>(
136 &self,
137 listen_addr: SocketAddr,
138 hook: Arc<F>,
139 task_executor: TaskExecutor,
140 pprof_dump_dir: PathBuf,
141 ) -> eyre::Result<()> {
142 let listener = tokio::net::TcpListener::bind(listen_addr)
143 .await
144 .wrap_err("Could not bind to address")?;
145
146 tracing::info!(target: "reth::cli", "Starting metrics endpoint at {}", listener.local_addr().unwrap());
147
148 let executor = task_executor.clone();
149 task_executor.spawn_with_graceful_shutdown_signal(async move |mut signal| loop {
150 let io = tokio::select! {
151 _ = &mut signal => break,
152 io = listener.accept() => {
153 match io {
154 Ok((stream, _remote_addr)) => stream,
155 Err(err) => {
156 tracing::error!(%err, "failed to accept connection");
157 continue;
158 }
159 }
160 }
161 };
162
163 let handle = install_prometheus_recorder();
164 let hook = hook.clone();
165 let pprof_dump_dir = pprof_dump_dir.clone();
166 let executor = executor.clone();
167 let service = tower::service_fn(move |req: Request<_>| {
168 let hook = hook.clone();
169 let pprof_dump_dir = pprof_dump_dir.clone();
170 let executor = executor.clone();
171 async move {
172 let response =
173 handle_request(req.uri().path(), hook, executor, handle, &pprof_dump_dir)
174 .await;
175 Ok::<_, Infallible>(response)
176 }
177 });
178
179 let mut shutdown = signal.clone().ignore_guard();
180 tokio::task::spawn(async move {
181 let _ = jsonrpsee_server::serve_with_graceful_shutdown(io, service, &mut shutdown)
182 .await
183 .inspect_err(|error| tracing::debug!(%error, "failed to serve request"));
184 });
185 });
186
187 Ok(())
188 }
189
190 fn start_push_gateway_task(
192 &self,
193 url: String,
194 interval: Duration,
195 hooks: Hooks,
196 task_executor: TaskExecutor,
197 ) -> eyre::Result<()> {
198 let client = Client::builder()
199 .build()
200 .wrap_err("Could not create HTTP client to push metrics to gateway")?;
201 let executor = task_executor.clone();
202 task_executor.spawn_with_graceful_shutdown_signal(async move |mut signal| {
203 tracing::info!(url = %url, interval = ?interval, "Starting task to push metrics to gateway");
204 let handle = install_prometheus_recorder();
205 loop {
206 tokio::select! {
207 _ = &mut signal => {
208 tracing::info!("Shutting down task to push metrics to gateway");
209 break;
210 }
211 _ = tokio::time::sleep(interval) => {
212 let hooks = hooks.clone();
213 let metrics_handle = handle.handle().clone();
214 let metrics = match executor.spawn_blocking(move || {
215 hooks.iter().for_each(|hook| hook());
216 metrics_handle.render()
217 }).await {
218 Ok(metrics) => metrics,
219 Err(err) => {
220 tracing::warn!(%err, "Failed to collect metrics for gateway");
221 continue;
222 }
223 };
224 match client.put(&url).header("Content-Type", "text/plain").body(metrics).send().await {
225 Ok(response) => {
226 if !response.status().is_success() {
227 tracing::warn!(
228 status = %response.status(),
229 "Failed to push metrics to gateway"
230 );
231 }
232 }
233 Err(err) => {
234 tracing::warn!(%err, "Failed to push metrics to gateway");
235 }
236 }
237 }
238 }
239 }
240 });
241 Ok(())
242 }
243}
244
245fn describe_db_metrics() {
246 describe_gauge!("db.table_size", Unit::Bytes, "The size of a database table (in bytes)");
247 describe_gauge!("db.table_pages", "The number of database pages for a table");
248 describe_gauge!("db.table_entries", "The number of entries for a table");
249 describe_gauge!("db.freelist", "The number of pages on the freelist");
250 describe_gauge!("db.page_size", Unit::Bytes, "The size of a database page (in bytes)");
251 describe_gauge!(
252 "db.timed_out_not_aborted_transactions",
253 "Number of timed out transactions that were not aborted by the user yet"
254 );
255}
256
257fn describe_static_file_metrics() {
258 describe_gauge!("static_files.segment_size", Unit::Bytes, "The size of a static file segment");
259 describe_gauge!("static_files.segment_files", "The number of files for a static file segment");
260 describe_gauge!(
261 "static_files.segment_entries",
262 "The number of entries for a static file segment"
263 );
264}
265
266fn describe_rocksdb_metrics() {
267 describe_gauge!(
268 "rocksdb.table_size",
269 Unit::Bytes,
270 "The estimated size of a RocksDB table (SST + memtable)"
271 );
272 describe_gauge!("rocksdb.table_entries", "The estimated number of keys in a RocksDB table");
273 describe_gauge!(
274 "rocksdb.pending_compaction_bytes",
275 Unit::Bytes,
276 "Bytes pending compaction for a RocksDB table"
277 );
278 describe_gauge!("rocksdb.sst_size", Unit::Bytes, "The size of SST files for a RocksDB table");
279 describe_gauge!(
280 "rocksdb.memtable_size",
281 Unit::Bytes,
282 "The size of memtables for a RocksDB table"
283 );
284 describe_gauge!(
285 "rocksdb.wal_size",
286 Unit::Bytes,
287 "The total size of WAL (Write-Ahead Log) files. Important: this is not included in table_size or sst_size metrics"
288 );
289}
290
291#[cfg(all(feature = "jemalloc", unix))]
292fn describe_memory_stats() {
293 describe_gauge!(
294 "jemalloc.active",
295 Unit::Bytes,
296 "Total number of bytes in active pages allocated by the application"
297 );
298 describe_gauge!(
299 "jemalloc.allocated",
300 Unit::Bytes,
301 "Total number of bytes allocated by the application"
302 );
303 describe_gauge!(
304 "jemalloc.mapped",
305 Unit::Bytes,
306 "Total number of bytes in active extents mapped by the allocator"
307 );
308 describe_gauge!(
309 "jemalloc.metadata",
310 Unit::Bytes,
311 "Total number of bytes dedicated to jemalloc metadata"
312 );
313 describe_gauge!(
314 "jemalloc.resident",
315 Unit::Bytes,
316 "Total number of bytes in physically resident data pages mapped by the allocator"
317 );
318 describe_gauge!(
319 "jemalloc.retained",
320 Unit::Bytes,
321 "Total number of bytes in virtual memory mappings that were retained rather than \
322 being returned to the operating system via e.g. munmap(2)"
323 );
324}
325
326#[cfg(not(all(feature = "jemalloc", unix)))]
327const fn describe_memory_stats() {}
328
329#[cfg(target_os = "linux")]
330fn describe_io_stats() {
331 use metrics::describe_counter;
332
333 describe_counter!("io.rchar", "Characters read");
334 describe_counter!("io.wchar", "Characters written");
335 describe_counter!("io.syscr", "Read syscalls");
336 describe_counter!("io.syscw", "Write syscalls");
337 describe_counter!("io.read_bytes", Unit::Bytes, "Bytes read");
338 describe_counter!("io.write_bytes", Unit::Bytes, "Bytes written");
339 describe_counter!("io.cancelled_write_bytes", Unit::Bytes, "Cancelled write bytes");
340}
341
342#[cfg(not(target_os = "linux"))]
343const fn describe_io_stats() {}
344
345async fn handle_request<F: Hook>(
346 path: &str,
347 hook: Arc<F>,
348 executor: TaskExecutor,
349 handle: &crate::recorder::PrometheusRecorder,
350 pprof_dump_dir: &PathBuf,
351) -> Response<Full<Bytes>> {
352 match path {
353 "/debug/pprof/heap" => handle_pprof_heap(pprof_dump_dir),
354 "/debug/tokio/dump" => handle_tokio_dump().await,
355 _ => {
356 let metrics_handle = handle.handle().clone();
357 let metrics = match executor
358 .spawn_blocking(move || {
359 hook();
360 metrics_handle.render()
361 })
362 .await
363 {
364 Ok(metrics) => metrics,
365 Err(err) => {
366 let mut response = Response::new(Full::new(Bytes::from(format!(
367 "Failed to collect metrics: {err}"
368 ))));
369 *response.status_mut() = StatusCode::INTERNAL_SERVER_ERROR;
370 return response;
371 }
372 };
373 let mut response = Response::new(Full::new(Bytes::from(metrics)));
374 response.headers_mut().insert(CONTENT_TYPE, HeaderValue::from_static("text/plain"));
375 response
376 }
377 }
378}
379
380#[cfg(all(feature = "jemalloc-prof", unix))]
381fn handle_pprof_heap(pprof_dump_dir: &PathBuf) -> Response<Full<Bytes>> {
382 use http::header::CONTENT_ENCODING;
383
384 match jemalloc_pprof::PROF_CTL.as_ref() {
385 Some(prof_ctl) => match prof_ctl.try_lock() {
386 Ok(_) => match jemalloc_pprof_dump(pprof_dump_dir) {
387 Ok(pprof) => {
388 let mut response = Response::new(Full::new(Bytes::from(pprof)));
389 response
390 .headers_mut()
391 .insert(CONTENT_TYPE, HeaderValue::from_static("application/octet-stream"));
392 response
393 .headers_mut()
394 .insert(CONTENT_ENCODING, HeaderValue::from_static("gzip"));
395 response
396 }
397 Err(err) => {
398 let mut response = Response::new(Full::new(Bytes::from(format!(
399 "Failed to dump pprof: {err}"
400 ))));
401 *response.status_mut() = StatusCode::INTERNAL_SERVER_ERROR;
402 response
403 }
404 },
405 Err(_) => {
406 let mut response = Response::new(Full::new(Bytes::from_static(
407 b"Profile dump already in progress. Try again later.",
408 )));
409 *response.status_mut() = StatusCode::SERVICE_UNAVAILABLE;
410 response
411 }
412 },
413 None => {
414 let mut response = Response::new(Full::new(Bytes::from_static(
415 b"jemalloc profiling not enabled. \
416 Set MALLOC_CONF=prof:true or rebuild with jemalloc-prof feature.",
417 )));
418 *response.status_mut() = StatusCode::INTERNAL_SERVER_ERROR;
419 response
420 }
421 }
422}
423
424#[cfg(all(feature = "jemalloc-prof", unix))]
427fn jemalloc_pprof_dump(pprof_dump_dir: &PathBuf) -> eyre::Result<Vec<u8>> {
428 use std::{ffi::CString, io::BufReader};
429
430 use mappings::MAPPINGS;
431 use pprof_util::parse_jeheap;
432 use tempfile::NamedTempFile;
433
434 reth_fs_util::create_dir_all(pprof_dump_dir)?;
435 let f = NamedTempFile::new_in(pprof_dump_dir)?;
436 let path = CString::new(f.path().as_os_str().as_encoded_bytes()).unwrap();
437
438 unsafe { tikv_jemalloc_ctl::raw::write(b"prof.dump\0", path.as_ptr()) }?;
441
442 let dump_reader = BufReader::new(f);
443 let profile =
444 parse_jeheap(dump_reader, MAPPINGS.as_deref()).map_err(|err| eyre::eyre!(Box::new(err)))?;
445 let pprof = profile.to_pprof(("inuse_space", "bytes"), ("space", "bytes"), None);
446
447 Ok(pprof)
448}
449
450#[cfg(not(all(feature = "jemalloc-prof", unix)))]
451fn handle_pprof_heap(_pprof_dump_dir: &PathBuf) -> Response<Full<Bytes>> {
452 let mut response = Response::new(Full::new(Bytes::from_static(
453 b"jemalloc pprof support not compiled. Rebuild with the jemalloc-prof feature.",
454 )));
455 *response.status_mut() = StatusCode::NOT_IMPLEMENTED;
456 response
457}
458
459#[cfg(tokio_unstable)]
460async fn handle_tokio_dump() -> Response<Full<Bytes>> {
461 let handle = tokio::runtime::Handle::current();
462 let dump = handle.dump().await;
463
464 let mut output = String::new();
465 for (i, task) in dump.tasks().iter().enumerate() {
466 let trace = task.trace();
467 output.push_str(&format!("task {i}:\n{trace}\n\n"));
468 }
469
470 let mut response = Response::new(Full::new(Bytes::from(output)));
471 response.headers_mut().insert(CONTENT_TYPE, HeaderValue::from_static("text/plain"));
472 response
473}
474
475#[cfg(not(tokio_unstable))]
476async fn handle_tokio_dump() -> Response<Full<Bytes>> {
477 let mut response = Response::new(Full::new(Bytes::from_static(
478 b"tokio task dump not available. Rebuild with RUSTFLAGS=\"--cfg tokio_unstable\" and tokio's `taskdump` feature.",
479 )));
480 *response.status_mut() = StatusCode::NOT_IMPLEMENTED;
481 response
482}
483
484#[cfg(test)]
485mod tests {
486 use super::*;
487 use reqwest::Client;
488 use reth_tasks::Runtime;
489 use socket2::{Domain, Socket, Type};
490 use std::net::{SocketAddr, TcpListener};
491
492 fn get_random_available_addr() -> SocketAddr {
493 let addr = &"127.0.0.1:0".parse::<SocketAddr>().unwrap().into();
494 let socket = Socket::new(Domain::IPV4, Type::STREAM, None).unwrap();
495 socket.set_reuse_address(true).unwrap();
496 socket.bind(addr).unwrap();
497 socket.listen(1).unwrap();
498 let listener = TcpListener::from(socket);
499 listener.local_addr().unwrap()
500 }
501
502 #[tokio::test(flavor = "multi_thread")]
503 async fn test_metrics_endpoint() {
504 install_prometheus_recorder();
507
508 let chain_spec_info = ChainSpecInfo { name: "test".to_string() };
509 let storage_settings_info = StorageSettingsInfo {
510 storage_v2: true,
511 pruning_mode: "archive",
512 prune_config: r#"{"block_interval":5}"#.to_string(),
513 };
514 let version_info = VersionInfo {
515 version: "test",
516 build_timestamp: "test",
517 cargo_features: "test",
518 git_sha: "test",
519 target_triple: "test",
520 build_profile: "test",
521 };
522
523 let runtime = Runtime::test();
524
525 let hooks = Hooks::builder().build();
526
527 let listen_addr = get_random_available_addr();
528 let config = MetricServerConfig::new(
529 listen_addr,
530 version_info,
531 chain_spec_info,
532 runtime.clone(),
533 hooks,
534 std::env::temp_dir(),
535 )
536 .with_storage_settings_info(storage_settings_info);
537
538 MetricServer::new(config).serve().await.unwrap();
539
540 let url = format!("http://{listen_addr}");
542 let response = Client::new().get(&url).send().await.unwrap();
543 assert!(response.status().is_success());
544
545 let body = response.text().await.unwrap();
547 assert!(body.contains("reth_process_cpu_seconds_total"));
548 assert!(body.contains("reth_process_start_time_seconds"));
549 assert!(body.contains("process_cli_args"), "expected process_cli_args metric in output");
550 assert!(body.contains("reth_storage_settings"), "expected storage settings metric");
551 assert!(body.contains("storage_v2=\"true\""), "expected storage v2 label");
552 assert!(body.contains("pruning_mode=\"archive\""), "expected pruning mode label");
553 assert!(body.contains("prune_config="), "expected prune config label");
554
555 drop(runtime);
557 }
558}