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 let executor_for_hooks = task_executor.clone();
99 self.start_endpoint(
100 *listen_addr,
101 Arc::new(move || {
102 hooks_for_endpoint.refresh_background(&executor_for_hooks);
103 hooks_for_endpoint.iter().for_each(|hook| hook());
104 }),
105 task_executor.clone(),
106 pprof_dump_dir.clone(),
107 )
108 .await
109 .wrap_err_with(|| format!("Could not start Prometheus endpoint at {listen_addr}"))?;
110
111 if let Some(url) = push_gateway_url {
113 self.start_push_gateway_task(
114 url.clone(),
115 *push_gateway_interval,
116 hooks.clone(),
117 task_executor.clone(),
118 )?;
119 }
120
121 describe_db_metrics();
123 describe_static_file_metrics();
124 describe_rocksdb_metrics();
125 Collector::default().describe();
126 describe_memory_stats();
127 describe_io_stats();
128
129 version_info.register_version_metrics();
130 chain_spec_info.register_chain_spec_metrics();
131 if let Some(storage_settings_info) = storage_settings_info {
132 storage_settings_info.register_storage_settings_metrics();
133 }
134 register_process_metrics();
135
136 Ok(())
137 }
138
139 async fn start_endpoint<F: Hook + 'static>(
140 &self,
141 listen_addr: SocketAddr,
142 hook: Arc<F>,
143 task_executor: TaskExecutor,
144 pprof_dump_dir: PathBuf,
145 ) -> eyre::Result<()> {
146 let listener = tokio::net::TcpListener::bind(listen_addr)
147 .await
148 .wrap_err("Could not bind to address")?;
149
150 tracing::info!(target: "reth::cli", "Starting metrics endpoint at {}", listener.local_addr().unwrap());
151
152 let executor = task_executor.clone();
153 task_executor.spawn_with_graceful_shutdown_signal(async move |mut signal| loop {
154 let io = tokio::select! {
155 _ = &mut signal => break,
156 io = listener.accept() => {
157 match io {
158 Ok((stream, _remote_addr)) => stream,
159 Err(err) => {
160 tracing::error!(%err, "failed to accept connection");
161 continue;
162 }
163 }
164 }
165 };
166
167 let handle = install_prometheus_recorder();
168 let hook = hook.clone();
169 let pprof_dump_dir = pprof_dump_dir.clone();
170 let executor = executor.clone();
171 let service = tower::service_fn(move |req: Request<_>| {
172 let hook = hook.clone();
173 let pprof_dump_dir = pprof_dump_dir.clone();
174 let executor = executor.clone();
175 async move {
176 let response =
177 handle_request(req.uri().path(), hook, executor, handle, &pprof_dump_dir)
178 .await;
179 Ok::<_, Infallible>(response)
180 }
181 });
182
183 let mut shutdown = signal.clone().ignore_guard();
184 tokio::task::spawn(async move {
185 let _ = jsonrpsee_server::serve_with_graceful_shutdown(io, service, &mut shutdown)
186 .await
187 .inspect_err(|error| tracing::debug!(%error, "failed to serve request"));
188 });
189 });
190
191 Ok(())
192 }
193
194 fn start_push_gateway_task(
196 &self,
197 url: String,
198 interval: Duration,
199 hooks: Hooks,
200 task_executor: TaskExecutor,
201 ) -> eyre::Result<()> {
202 let client = Client::builder()
203 .build()
204 .wrap_err("Could not create HTTP client to push metrics to gateway")?;
205 let executor = task_executor.clone();
206 task_executor.spawn_with_graceful_shutdown_signal(async move |mut signal| {
207 tracing::info!(url = %url, interval = ?interval, "Starting task to push metrics to gateway");
208 let handle = install_prometheus_recorder();
209 loop {
210 tokio::select! {
211 _ = &mut signal => {
212 tracing::info!("Shutting down task to push metrics to gateway");
213 break;
214 }
215 _ = tokio::time::sleep(interval) => {
216 hooks.refresh_background(&executor);
217 let hooks = hooks.clone();
218 let metrics_handle = handle.handle().clone();
219 let metrics = match executor.spawn_blocking(move || {
220 hooks.iter().for_each(|hook| hook());
221 metrics_handle.render()
222 }).await {
223 Ok(metrics) => metrics,
224 Err(err) => {
225 tracing::warn!(%err, "Failed to collect metrics for gateway");
226 continue;
227 }
228 };
229 match client.put(&url).header("Content-Type", "text/plain").body(metrics).send().await {
230 Ok(response) => {
231 if !response.status().is_success() {
232 tracing::warn!(
233 status = %response.status(),
234 "Failed to push metrics to gateway"
235 );
236 }
237 }
238 Err(err) => {
239 tracing::warn!(%err, "Failed to push metrics to gateway");
240 }
241 }
242 }
243 }
244 }
245 });
246 Ok(())
247 }
248}
249
250fn describe_db_metrics() {
251 describe_gauge!("db.table_size", Unit::Bytes, "The size of a database table (in bytes)");
252 describe_gauge!("db.table_pages", "The number of database pages for a table");
253 describe_gauge!("db.table_entries", "The number of entries for a table");
254 describe_gauge!("db.freelist", "The number of pages on the freelist");
255 describe_gauge!("db.page_size", Unit::Bytes, "The size of a database page (in bytes)");
256 describe_gauge!(
257 "db.timed_out_not_aborted_transactions",
258 "Number of timed out transactions that were not aborted by the user yet"
259 );
260}
261
262fn describe_static_file_metrics() {
263 describe_gauge!("static_files.segment_size", Unit::Bytes, "The size of a static file segment");
264 describe_gauge!("static_files.segment_files", "The number of files for a static file segment");
265 describe_gauge!(
266 "static_files.segment_entries",
267 "The number of entries for a static file segment"
268 );
269}
270
271fn describe_rocksdb_metrics() {
272 describe_gauge!(
273 "rocksdb.table_size",
274 Unit::Bytes,
275 "The estimated size of a RocksDB table (SST + memtable)"
276 );
277 describe_gauge!("rocksdb.table_entries", "The estimated number of keys in a RocksDB table");
278 describe_gauge!(
279 "rocksdb.pending_compaction_bytes",
280 Unit::Bytes,
281 "Bytes pending compaction for a RocksDB table"
282 );
283 describe_gauge!("rocksdb.sst_size", Unit::Bytes, "The size of SST files for a RocksDB table");
284 describe_gauge!(
285 "rocksdb.memtable_size",
286 Unit::Bytes,
287 "The size of memtables for a RocksDB table"
288 );
289 describe_gauge!(
290 "rocksdb.wal_size",
291 Unit::Bytes,
292 "The total size of WAL (Write-Ahead Log) files. Important: this is not included in table_size or sst_size metrics"
293 );
294}
295
296#[cfg(all(feature = "jemalloc", unix))]
297fn describe_memory_stats() {
298 describe_gauge!(
299 "jemalloc.active",
300 Unit::Bytes,
301 "Total number of bytes in active pages allocated by the application"
302 );
303 describe_gauge!(
304 "jemalloc.allocated",
305 Unit::Bytes,
306 "Total number of bytes allocated by the application"
307 );
308 describe_gauge!(
309 "jemalloc.mapped",
310 Unit::Bytes,
311 "Total number of bytes in active extents mapped by the allocator"
312 );
313 describe_gauge!(
314 "jemalloc.metadata",
315 Unit::Bytes,
316 "Total number of bytes dedicated to jemalloc metadata"
317 );
318 describe_gauge!(
319 "jemalloc.resident",
320 Unit::Bytes,
321 "Total number of bytes in physically resident data pages mapped by the allocator"
322 );
323 describe_gauge!(
324 "jemalloc.retained",
325 Unit::Bytes,
326 "Total number of bytes in virtual memory mappings that were retained rather than \
327 being returned to the operating system via e.g. munmap(2)"
328 );
329}
330
331#[cfg(not(all(feature = "jemalloc", unix)))]
332const fn describe_memory_stats() {}
333
334#[cfg(target_os = "linux")]
335fn describe_io_stats() {
336 use metrics::describe_counter;
337
338 describe_counter!("io.rchar", "Characters read");
339 describe_counter!("io.wchar", "Characters written");
340 describe_counter!("io.syscr", "Read syscalls");
341 describe_counter!("io.syscw", "Write syscalls");
342 describe_counter!("io.read_bytes", Unit::Bytes, "Bytes read");
343 describe_counter!("io.write_bytes", Unit::Bytes, "Bytes written");
344 describe_counter!("io.cancelled_write_bytes", Unit::Bytes, "Cancelled write bytes");
345}
346
347#[cfg(not(target_os = "linux"))]
348const fn describe_io_stats() {}
349
350async fn handle_request<F: Hook>(
351 path: &str,
352 hook: Arc<F>,
353 executor: TaskExecutor,
354 handle: &crate::recorder::PrometheusRecorder,
355 pprof_dump_dir: &PathBuf,
356) -> Response<Full<Bytes>> {
357 match path {
358 "/debug/pprof/heap" => handle_pprof_heap(pprof_dump_dir),
359 "/debug/tokio/dump" => handle_tokio_dump().await,
360 _ => {
361 let metrics_handle = handle.handle().clone();
362 let metrics = match executor
363 .spawn_blocking(move || {
364 hook();
365 metrics_handle.render()
366 })
367 .await
368 {
369 Ok(metrics) => metrics,
370 Err(err) => {
371 let mut response = Response::new(Full::new(Bytes::from(format!(
372 "Failed to collect metrics: {err}"
373 ))));
374 *response.status_mut() = StatusCode::INTERNAL_SERVER_ERROR;
375 return response;
376 }
377 };
378 let mut response = Response::new(Full::new(Bytes::from(metrics)));
379 response.headers_mut().insert(CONTENT_TYPE, HeaderValue::from_static("text/plain"));
380 response
381 }
382 }
383}
384
385#[cfg(all(feature = "jemalloc-prof", unix))]
386fn handle_pprof_heap(pprof_dump_dir: &PathBuf) -> Response<Full<Bytes>> {
387 use http::header::CONTENT_ENCODING;
388
389 match jemalloc_pprof::PROF_CTL.as_ref() {
390 Some(prof_ctl) => match prof_ctl.try_lock() {
391 Ok(_) => match jemalloc_pprof_dump(pprof_dump_dir) {
392 Ok(pprof) => {
393 let mut response = Response::new(Full::new(Bytes::from(pprof)));
394 response
395 .headers_mut()
396 .insert(CONTENT_TYPE, HeaderValue::from_static("application/octet-stream"));
397 response
398 .headers_mut()
399 .insert(CONTENT_ENCODING, HeaderValue::from_static("gzip"));
400 response
401 }
402 Err(err) => {
403 let mut response = Response::new(Full::new(Bytes::from(format!(
404 "Failed to dump pprof: {err}"
405 ))));
406 *response.status_mut() = StatusCode::INTERNAL_SERVER_ERROR;
407 response
408 }
409 },
410 Err(_) => {
411 let mut response = Response::new(Full::new(Bytes::from_static(
412 b"Profile dump already in progress. Try again later.",
413 )));
414 *response.status_mut() = StatusCode::SERVICE_UNAVAILABLE;
415 response
416 }
417 },
418 None => {
419 let mut response = Response::new(Full::new(Bytes::from_static(
420 b"jemalloc profiling not enabled. \
421 Set MALLOC_CONF=prof:true or rebuild with jemalloc-prof feature.",
422 )));
423 *response.status_mut() = StatusCode::INTERNAL_SERVER_ERROR;
424 response
425 }
426 }
427}
428
429#[cfg(all(feature = "jemalloc-prof", unix))]
432fn jemalloc_pprof_dump(pprof_dump_dir: &PathBuf) -> eyre::Result<Vec<u8>> {
433 use std::{ffi::CString, io::BufReader};
434
435 use mappings::MAPPINGS;
436 use pprof_util::parse_jeheap;
437 use tempfile::NamedTempFile;
438
439 reth_fs_util::create_dir_all(pprof_dump_dir)?;
440 let f = NamedTempFile::new_in(pprof_dump_dir)?;
441 let path = CString::new(f.path().as_os_str().as_encoded_bytes()).unwrap();
442
443 unsafe { tikv_jemalloc_ctl::raw::write(b"prof.dump\0", path.as_ptr()) }?;
446
447 let dump_reader = BufReader::new(f);
448 let profile =
449 parse_jeheap(dump_reader, MAPPINGS.as_deref()).map_err(|err| eyre::eyre!(Box::new(err)))?;
450 let pprof = profile.to_pprof(("inuse_space", "bytes"), ("space", "bytes"), None);
451
452 Ok(pprof)
453}
454
455#[cfg(not(all(feature = "jemalloc-prof", unix)))]
456fn handle_pprof_heap(_pprof_dump_dir: &PathBuf) -> Response<Full<Bytes>> {
457 let mut response = Response::new(Full::new(Bytes::from_static(
458 b"jemalloc pprof support not compiled. Rebuild with the jemalloc-prof feature.",
459 )));
460 *response.status_mut() = StatusCode::NOT_IMPLEMENTED;
461 response
462}
463
464#[cfg(tokio_unstable)]
465async fn handle_tokio_dump() -> Response<Full<Bytes>> {
466 let handle = tokio::runtime::Handle::current();
467 let dump = handle.dump().await;
468
469 let mut output = String::new();
470 for (i, task) in dump.tasks().iter().enumerate() {
471 let trace = task.trace();
472 output.push_str(&format!("task {i}:\n{trace}\n\n"));
473 }
474
475 let mut response = Response::new(Full::new(Bytes::from(output)));
476 response.headers_mut().insert(CONTENT_TYPE, HeaderValue::from_static("text/plain"));
477 response
478}
479
480#[cfg(not(tokio_unstable))]
481async fn handle_tokio_dump() -> Response<Full<Bytes>> {
482 let mut response = Response::new(Full::new(Bytes::from_static(
483 b"tokio task dump not available. Rebuild with RUSTFLAGS=\"--cfg tokio_unstable\" and tokio's `taskdump` feature.",
484 )));
485 *response.status_mut() = StatusCode::NOT_IMPLEMENTED;
486 response
487}
488
489#[cfg(test)]
490mod tests {
491 use super::*;
492 use reqwest::Client;
493 use reth_tasks::Runtime;
494 use socket2::{Domain, Socket, Type};
495 use std::{
496 net::{SocketAddr, TcpListener},
497 sync::{
498 atomic::{AtomicUsize, Ordering},
499 mpsc, Mutex,
500 },
501 };
502
503 fn get_random_available_addr() -> SocketAddr {
504 let addr = &"127.0.0.1:0".parse::<SocketAddr>().unwrap().into();
505 let socket = Socket::new(Domain::IPV4, Type::STREAM, None).unwrap();
506 socket.set_reuse_address(true).unwrap();
507 socket.bind(addr).unwrap();
508 socket.listen(1).unwrap();
509 let listener = TcpListener::from(socket);
510 listener.local_addr().unwrap()
511 }
512
513 #[tokio::test(flavor = "multi_thread")]
514 async fn test_metrics_endpoint() {
515 install_prometheus_recorder();
518
519 let chain_spec_info = ChainSpecInfo { name: "test".to_string() };
520 let storage_settings_info = StorageSettingsInfo {
521 storage_v2: true,
522 pruning_mode: "archive",
523 prune_config: r#"{"block_interval":5}"#.to_string(),
524 };
525 let version_info = VersionInfo {
526 version: "test",
527 build_timestamp: "test",
528 cargo_features: "test",
529 git_sha: "test",
530 target_triple: "test",
531 build_profile: "test",
532 };
533
534 let runtime = Runtime::test();
535
536 let hooks = Hooks::builder().build();
537
538 let listen_addr = get_random_available_addr();
539 let config = MetricServerConfig::new(
540 listen_addr,
541 version_info,
542 chain_spec_info,
543 runtime.clone(),
544 hooks,
545 std::env::temp_dir(),
546 )
547 .with_storage_settings_info(storage_settings_info);
548
549 MetricServer::new(config).serve().await.unwrap();
550
551 let url = format!("http://{listen_addr}");
553 let response = Client::new().get(&url).send().await.unwrap();
554 assert!(response.status().is_success());
555
556 let body = response.text().await.unwrap();
558 assert!(body.contains("reth_process_cpu_seconds_total"));
559 assert!(body.contains("reth_process_start_time_seconds"));
560 assert!(body.contains("process_cli_args"), "expected process_cli_args metric in output");
561 assert!(body.contains("reth_storage_settings"), "expected storage settings metric");
562 assert!(body.contains("storage_v2=\"true\""), "expected storage v2 label");
563 assert!(body.contains("pruning_mode=\"archive\""), "expected pruning mode label");
564 assert!(body.contains("prune_config="), "expected prune config label");
565
566 drop(runtime);
568 }
569
570 #[tokio::test(flavor = "multi_thread")]
571 async fn test_background_hooks_do_not_block_collection() {
572 install_prometheus_recorder();
573
574 let collections = Arc::new(AtomicUsize::new(0));
575 let (release, wait_for_release) = mpsc::channel();
576 let wait_for_release = Mutex::new(Some(wait_for_release));
577 let completed = Arc::new(tokio::sync::Notify::new());
578 let hooks = Hooks::builder()
579 .with_background_interval(Duration::from_secs(60))
580 .with_background_hook({
581 let collections = collections.clone();
582 let completed = completed.clone();
583 move || {
584 if let Some(wait_for_release) = wait_for_release.lock().unwrap().take() &&
586 wait_for_release.recv().is_err()
587 {
588 return
589 }
590 let collections = collections.fetch_add(1, Ordering::Relaxed) + 1;
591 metrics::gauge!("test_background_hook_collections").set(collections as f64);
592 completed.notify_one();
593 }
594 })
595 .build();
596
597 let runtime = Runtime::test();
598 let listen_addr = get_random_available_addr();
599 let config = MetricServerConfig::new(
600 listen_addr,
601 VersionInfo {
602 version: "test",
603 build_timestamp: "test",
604 cargo_features: "test",
605 git_sha: "test",
606 target_triple: "test",
607 build_profile: "test",
608 },
609 ChainSpecInfo { name: "test".to_string() },
610 runtime.clone(),
611 hooks,
612 std::env::temp_dir(),
613 );
614
615 MetricServer::new(config).serve().await.unwrap();
616
617 let url = format!("http://{listen_addr}");
619 let started = std::time::Instant::now();
620 let response =
621 Client::new().get(&url).timeout(Duration::from_millis(500)).send().await.unwrap();
622 assert!(response.status().is_success());
623 assert!(started.elapsed() < Duration::from_secs(1), "scrape waited for the hook");
624 assert_eq!(collections.load(Ordering::Relaxed), 0);
625
626 release.send(()).unwrap();
628 tokio::time::timeout(Duration::from_secs(10), completed.notified()).await.unwrap();
629 let body = Client::new().get(&url).send().await.unwrap().text().await.unwrap();
630 assert!(
631 body.contains("test_background_hook_collections"),
632 "background hook metric missing: {body}"
633 );
634
635 assert_eq!(collections.load(Ordering::Relaxed), 1);
637
638 drop(runtime);
639 }
640}