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        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        // Start push-gateway task if configured
108        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 metrics after recorder installation
118        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    /// Starts a background task to push metrics to a metrics gateway
191    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/// Equivalent to [`jemalloc_pprof::JemallocProfCtl::dump`], but accepts a directory that the
425/// temporary pprof file will be written to. The file is deleted when the function exits.
426#[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    // SAFETY: "prof.dump" is documented as being writable and taking a C string as input:
439    // http://jemalloc.net/jemalloc.3.html#prof.dump
440    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 the recorder before serve() so gauge registrations are captured,
505        // mirroring how start_prometheus_endpoint() works in the real node launch.
506        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        // Send request to the metrics endpoint
541        let url = format!("http://{listen_addr}");
542        let response = Client::new().get(&url).send().await.unwrap();
543        assert!(response.status().is_success());
544
545        // Check the response body
546        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        // Make sure the runtime is dropped after the test runs.
556        drop(runtime);
557    }
558}