Skip to main content

reth_node_metrics/
server.rs

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/// Configuration for the [`MetricServer`]
21#[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    /// Create a new [`MetricServerConfig`] with the given configuration
36    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    /// Set the storage settings information to expose over prometheus.
58    pub fn with_storage_settings_info(mut self, info: StorageSettingsInfo) -> Self {
59        self.storage_settings_info = Some(info);
60        self
61    }
62
63    /// Set the gateway URL and interval for pushing metrics
64    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/// [`MetricServer`] responsible for serving the metrics endpoint
72#[derive(Debug)]
73pub struct MetricServer {
74    config: MetricServerConfig,
75}
76
77impl MetricServer {
78    /// Create a new [`MetricServer`] with the given configuration
79    pub const fn new(config: MetricServerConfig) -> Self {
80        Self { config }
81    }
82
83    /// Spawns the metrics server
84    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        // Start push-gateway task if configured
112        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 metrics after recorder installation
122        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    /// Starts a background task to push metrics to a metrics gateway
195    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/// Equivalent to [`jemalloc_pprof::JemallocProfCtl::dump`], but accepts a directory that the
430/// temporary pprof file will be written to. The file is deleted when the function exits.
431#[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    // SAFETY: "prof.dump" is documented as being writable and taking a C string as input:
444    // http://jemalloc.net/jemalloc.3.html#prof.dump
445    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 the recorder before serve() so gauge registrations are captured,
516        // mirroring how start_prometheus_endpoint() works in the real node launch.
517        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        // Send request to the metrics endpoint
552        let url = format!("http://{listen_addr}");
553        let response = Client::new().get(&url).send().await.unwrap();
554        assert!(response.status().is_success());
555
556        // Check the response body
557        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        // Make sure the runtime is dropped after the test runs.
567        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                    // Dropping the sender also unblocks the hook if a scrape assertion fails.
585                    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        // the first scrape kicks off the background hook but must not wait for it
618        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        // once it completes, its values are rendered by the following scrape
627        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        // and scrapes within the interval do not collect again
636        assert_eq!(collections.load(Ordering::Relaxed), 1);
637
638        drop(runtime);
639    }
640}