Skip to main content

rest/
devserver.rs

1use actix_web::{
2    dev::Server,
3    http::header,
4    web::{self, ServiceConfig},
5    App, HttpRequest, HttpResponse, HttpServer,
6};
7use rust_zero_core::{HealthRegistry, Metrics, Profiler};
8use serde::{Deserialize, Serialize};
9#[cfg(all(feature = "sampling-profiler", unix))]
10use std::time::Duration;
11use std::{
12    io,
13    net::IpAddr,
14    sync::Arc,
15    time::{Instant, SystemTime, UNIX_EPOCH},
16};
17
18/// Configuration for the framework's internal observability server.
19#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
20#[serde(default)]
21pub struct DevServerConfig {
22    pub enabled: bool,
23    pub host: String,
24    pub port: u16,
25    pub health_path: String,
26    pub metrics_path: String,
27    pub profile_path: String,
28    pub flamegraph_path: String,
29    pub runtime_path: String,
30    pub tasks_path: String,
31    pub allocator_path: String,
32    pub health_response: String,
33    pub enable_metrics: bool,
34    pub enable_profiling: bool,
35    pub enable_sampling_profiler: bool,
36    pub sampling_seconds: u64,
37    pub sampling_frequency: i32,
38    /// When set, every diagnostic endpoint requires this bearer token.
39    #[serde(skip_serializing)]
40    pub auth_token: Option<String>,
41    /// Reject non-private listener addresses when starting the server.
42    pub private_only: bool,
43}
44
45impl std::fmt::Debug for DevServerConfig {
46    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47        formatter
48            .debug_struct("DevServerConfig")
49            .field("enabled", &self.enabled)
50            .field("host", &self.host)
51            .field("port", &self.port)
52            .field("health_path", &self.health_path)
53            .field("metrics_path", &self.metrics_path)
54            .field("profile_path", &self.profile_path)
55            .field("flamegraph_path", &self.flamegraph_path)
56            .field("runtime_path", &self.runtime_path)
57            .field("tasks_path", &self.tasks_path)
58            .field("allocator_path", &self.allocator_path)
59            .field("health_response", &self.health_response)
60            .field("enable_metrics", &self.enable_metrics)
61            .field("enable_profiling", &self.enable_profiling)
62            .field("enable_sampling_profiler", &self.enable_sampling_profiler)
63            .field("sampling_seconds", &self.sampling_seconds)
64            .field("sampling_frequency", &self.sampling_frequency)
65            .field(
66                "auth_token",
67                &self.auth_token.as_ref().map(|_| "[REDACTED]"),
68            )
69            .field("private_only", &self.private_only)
70            .finish()
71    }
72}
73
74impl Default for DevServerConfig {
75    fn default() -> Self {
76        Self {
77            enabled: true,
78            host: String::new(),
79            port: 6060,
80            health_path: "/healthz".to_owned(),
81            metrics_path: "/metrics".to_owned(),
82            profile_path: "/debug/profile".to_owned(),
83            flamegraph_path: "/debug/flamegraph".to_owned(),
84            runtime_path: "/debug/runtime".to_owned(),
85            tasks_path: "/debug/tasks".to_owned(),
86            allocator_path: "/debug/allocator".to_owned(),
87            health_response: "OK".to_owned(),
88            enable_metrics: true,
89            enable_profiling: true,
90            enable_sampling_profiler: false,
91            sampling_seconds: 10,
92            sampling_frequency: 99,
93            auth_token: None,
94            private_only: false,
95        }
96    }
97}
98
99impl DevServerConfig {
100    pub fn address(&self) -> String {
101        format!("{}:{}", self.host, self.port)
102    }
103
104    pub fn validate(&self) -> io::Result<()> {
105        if self.sampling_seconds == 0 || self.sampling_seconds > 300 {
106            return Err(io::Error::new(
107                io::ErrorKind::InvalidInput,
108                "sampling_seconds must be between 1 and 300",
109            ));
110        }
111        if !(1..=1_000).contains(&self.sampling_frequency) {
112            return Err(io::Error::new(
113                io::ErrorKind::InvalidInput,
114                "sampling_frequency must be between 1 and 1000",
115            ));
116        }
117        if self.auth_token.as_ref().is_some_and(String::is_empty) {
118            return Err(io::Error::new(
119                io::ErrorKind::InvalidInput,
120                "auth_token cannot be empty",
121            ));
122        }
123        if self.private_only {
124            let host = self.host.parse::<IpAddr>().map_err(|_| {
125                io::Error::new(
126                    io::ErrorKind::InvalidInput,
127                    "private_only requires a literal private IP address",
128                )
129            })?;
130            if !is_private_address(host) {
131                return Err(io::Error::new(
132                    io::ErrorKind::InvalidInput,
133                    "private_only rejects public or unspecified listener addresses",
134                ));
135            }
136        }
137        Ok(())
138    }
139}
140
141fn is_private_address(address: IpAddr) -> bool {
142    match address {
143        IpAddr::V4(address) => {
144            address.is_loopback() || address.is_private() || address.is_link_local()
145        }
146        IpAddr::V6(address) => {
147            address.is_loopback()
148                || address.is_unique_local()
149                || (address.segments()[0] & 0xffc0) == 0xfe80
150        }
151    }
152}
153
154/// Health, metrics, profiling, and runtime-diagnostic endpoints for a service.
155#[derive(Clone)]
156pub struct DevServer {
157    config: DevServerConfig,
158    metrics: Arc<Metrics>,
159    profiler: Arc<Profiler>,
160    health: HealthRegistry,
161    started_at: Instant,
162}
163
164impl DevServer {
165    pub fn new(config: DevServerConfig, metrics: Arc<Metrics>, profiler: Arc<Profiler>) -> Self {
166        if config.enable_profiling {
167            profiler.enable();
168        }
169        Self {
170            config,
171            metrics,
172            profiler,
173            health: HealthRegistry::new(),
174            started_at: Instant::now(),
175        }
176    }
177
178    pub fn with_health_registry(mut self, health: HealthRegistry) -> Self {
179        self.health = health;
180        self
181    }
182
183    pub fn health_registry(&self) -> HealthRegistry {
184        self.health.clone()
185    }
186
187    pub fn config(&self) -> &DevServerConfig {
188        &self.config
189    }
190
191    pub fn routes(&self) -> Vec<String> {
192        let mut routes = vec!["/".to_owned(), self.config.health_path.clone()];
193        if self.config.enable_metrics {
194            routes.push(self.config.metrics_path.clone());
195        }
196        if self.config.enable_profiling {
197            routes.push(self.config.profile_path.clone());
198        }
199        routes.push(self.config.runtime_path.clone());
200        routes.push(self.config.tasks_path.clone());
201        routes.push(self.config.allocator_path.clone());
202        if self.config.enable_sampling_profiler {
203            routes.push(self.config.flamegraph_path.clone());
204        }
205        routes
206    }
207
208    /// Registers the diagnostic routes in an existing Actix application.
209    pub fn configure(&self, services: &mut ServiceConfig) {
210        let routes = self.routes();
211        services
212            .app_data(web::Data::new(self.clone()))
213            .route(
214                "/",
215                web::get().to(move |request: HttpRequest, server: web::Data<Self>| {
216                    let routes = routes.clone();
217                    async move {
218                        if let Err(response) = server.authorize(&request) {
219                            return response;
220                        }
221                        HttpResponse::Ok().json(routes)
222                    }
223                }),
224            )
225            .route(
226                &self.config.health_path,
227                web::get().to(|request: HttpRequest, server: web::Data<Self>| async move {
228                    if let Err(response) = server.authorize(&request) {
229                        return response;
230                    }
231                    let health = server.health.snapshot();
232                    let mut response = if health.is_ready() {
233                        HttpResponse::Ok()
234                    } else {
235                        HttpResponse::ServiceUnavailable()
236                    };
237                    let body = if health.is_ready() {
238                        server.config.health_response.clone()
239                    } else {
240                        format!("NOT READY: {}", health.unhealthy().join(","))
241                    };
242                    response
243                        .insert_header((header::CONTENT_TYPE, "text/plain; charset=utf-8"))
244                        .body(body)
245                }),
246            )
247            .route(
248                &self.config.runtime_path,
249                web::get().to(|request: HttpRequest, server: web::Data<Self>| async move {
250                    if let Err(response) = server.authorize(&request) {
251                        return response;
252                    }
253                    HttpResponse::Ok().json(RuntimeStats::capture(server.started_at))
254                }),
255            )
256            .route(
257                &self.config.tasks_path,
258                web::get().to(|request: HttpRequest, server: web::Data<Self>| async move {
259                    if let Err(response) = server.authorize(&request) {
260                        return response;
261                    }
262                    HttpResponse::Ok().json(TaskStats::capture())
263                }),
264            )
265            .route(
266                &self.config.allocator_path,
267                web::get().to(|request: HttpRequest, server: web::Data<Self>| async move {
268                    if let Err(response) = server.authorize(&request) {
269                        return response;
270                    }
271                    HttpResponse::Ok().json(AllocatorStats::capture())
272                }),
273            );
274
275        if self.config.enable_metrics {
276            services.route(
277                &self.config.metrics_path,
278                web::get().to(|request: HttpRequest, server: web::Data<Self>| async move {
279                    if let Err(response) = server.authorize(&request) {
280                        return response;
281                    }
282                    HttpResponse::Ok()
283                        .insert_header((
284                            header::CONTENT_TYPE,
285                            "text/plain; version=0.0.4; charset=utf-8",
286                        ))
287                        .body(server.metrics.render())
288                }),
289            );
290        }
291
292        if self.config.enable_profiling {
293            services.route(
294                &self.config.profile_path,
295                web::get().to(|request: HttpRequest, server: web::Data<Self>| async move {
296                    if let Err(response) = server.authorize(&request) {
297                        return response;
298                    }
299                    HttpResponse::Ok()
300                        .insert_header((header::CONTENT_TYPE, "text/plain; charset=utf-8"))
301                        .body(server.profiler.render_report())
302                }),
303            );
304        }
305
306        if self.config.enable_sampling_profiler {
307            services.route(
308                &self.config.flamegraph_path,
309                web::get().to(|request: HttpRequest, server: web::Data<Self>| async move {
310                    if let Err(response) = server.authorize(&request) {
311                        return response;
312                    }
313                    server.flamegraph().await
314                }),
315            );
316        }
317    }
318
319    /// Builds the internal HTTP server. The returned future starts when it is awaited or spawned.
320    pub fn run(self) -> io::Result<Server> {
321        self.config.validate()?;
322        let address = self.config.address();
323        HttpServer::new(move || {
324            let server = self.clone();
325            App::new().configure(move |services| server.configure(services))
326        })
327        .bind(address)
328        .map(HttpServer::run)
329    }
330
331    fn authorize(&self, request: &HttpRequest) -> Result<(), HttpResponse> {
332        let Some(expected) = &self.config.auth_token else {
333            return Ok(());
334        };
335        let provided = request
336            .headers()
337            .get(header::AUTHORIZATION)
338            .and_then(|value| value.to_str().ok())
339            .and_then(|value| value.strip_prefix("Bearer "));
340        if provided
341            .is_some_and(|provided| constant_time_eq(provided.as_bytes(), expected.as_bytes()))
342        {
343            Ok(())
344        } else {
345            Err(HttpResponse::Unauthorized()
346                .insert_header((header::WWW_AUTHENTICATE, "Bearer"))
347                .finish())
348        }
349    }
350
351    async fn flamegraph(&self) -> HttpResponse {
352        #[cfg(all(feature = "sampling-profiler", unix))]
353        {
354            let seconds = self.config.sampling_seconds;
355            let frequency = self.config.sampling_frequency;
356            match tokio::task::spawn_blocking(move || render_flamegraph(seconds, frequency)).await {
357                Ok(Ok(svg)) => HttpResponse::Ok()
358                    .insert_header((header::CONTENT_TYPE, "image/svg+xml; charset=utf-8"))
359                    .body(svg),
360                Ok(Err(error)) => HttpResponse::InternalServerError().body(error),
361                Err(error) => HttpResponse::InternalServerError()
362                    .body(format!("sampling profiler task failed: {error}")),
363            }
364        }
365        #[cfg(not(all(feature = "sampling-profiler", unix)))]
366        HttpResponse::NotImplemented()
367            .body("sampling profiling requires the rest/sampling-profiler feature on a Unix target")
368    }
369}
370
371fn constant_time_eq(left: &[u8], right: &[u8]) -> bool {
372    let mut difference = left.len() ^ right.len();
373    for index in 0..left.len().max(right.len()) {
374        difference |= usize::from(
375            left.get(index).copied().unwrap_or_default()
376                ^ right.get(index).copied().unwrap_or_default(),
377        );
378    }
379    difference == 0
380}
381
382#[cfg(all(feature = "sampling-profiler", unix))]
383fn render_flamegraph(seconds: u64, frequency: i32) -> Result<Vec<u8>, String> {
384    let guard = pprof::ProfilerGuardBuilder::default()
385        .frequency(frequency)
386        .blocklist(&["libc", "libgcc", "pthread", "vdso"])
387        .build()
388        .map_err(|error| error.to_string())?;
389    std::thread::sleep(Duration::from_secs(seconds));
390    let report = guard.report().build().map_err(|error| error.to_string())?;
391    let mut svg = Vec::new();
392    report
393        .flamegraph(&mut svg)
394        .map_err(|error| error.to_string())?;
395    Ok(svg)
396}
397
398#[derive(Debug, Serialize)]
399struct RuntimeStats {
400    process_id: u32,
401    available_parallelism: usize,
402    uptime_seconds: f64,
403    unix_time_seconds: u64,
404}
405
406#[derive(Debug, Serialize)]
407struct TaskStats {
408    runtime_available: bool,
409    worker_threads: usize,
410    alive_tasks: usize,
411    global_queue_depth: usize,
412}
413
414impl TaskStats {
415    fn capture() -> Self {
416        match tokio::runtime::Handle::try_current() {
417            Ok(handle) => {
418                let metrics = handle.metrics();
419                Self {
420                    runtime_available: true,
421                    worker_threads: metrics.num_workers(),
422                    alive_tasks: metrics.num_alive_tasks(),
423                    global_queue_depth: metrics.global_queue_depth(),
424                }
425            }
426            Err(_) => Self {
427                runtime_available: false,
428                worker_threads: 0,
429                alive_tasks: 0,
430                global_queue_depth: 0,
431            },
432        }
433    }
434}
435
436#[derive(Debug, Serialize)]
437struct AllocatorStats {
438    allocator: &'static str,
439    resident_set_high_water_bytes: Option<u64>,
440}
441
442impl AllocatorStats {
443    fn capture() -> Self {
444        Self {
445            allocator: "system",
446            resident_set_high_water_bytes: resident_set_high_water_bytes(),
447        }
448    }
449}
450
451#[cfg(unix)]
452fn resident_set_high_water_bytes() -> Option<u64> {
453    let mut usage = std::mem::MaybeUninit::<libc::rusage>::zeroed();
454    // SAFETY: `usage` points to writable storage for the `rusage` result.
455    if unsafe { libc::getrusage(libc::RUSAGE_SELF, usage.as_mut_ptr()) } != 0 {
456        return None;
457    }
458    // SAFETY: successful `getrusage` initialized the structure.
459    let bytes = unsafe { usage.assume_init() }.ru_maxrss as u64;
460    #[cfg(target_os = "macos")]
461    return Some(bytes);
462    #[cfg(not(target_os = "macos"))]
463    Some(bytes.saturating_mul(1024))
464}
465
466#[cfg(not(unix))]
467fn resident_set_high_water_bytes() -> Option<u64> {
468    None
469}
470
471impl RuntimeStats {
472    fn capture(started_at: Instant) -> Self {
473        Self {
474            process_id: std::process::id(),
475            available_parallelism: std::thread::available_parallelism()
476                .map(usize::from)
477                .unwrap_or(1),
478            uptime_seconds: started_at.elapsed().as_secs_f64(),
479            unix_time_seconds: SystemTime::now()
480                .duration_since(UNIX_EPOCH)
481                .unwrap_or_default()
482                .as_secs(),
483        }
484    }
485}
486
487#[cfg(test)]
488mod tests {
489    use super::*;
490    use actix_web::{body::to_bytes, http::StatusCode, test};
491    use rust_zero_core::VectorOptions;
492    use std::time::Duration;
493
494    fn server(config: DevServerConfig) -> DevServer {
495        let metrics = Arc::new(Metrics::new());
496        metrics
497            .counter_vec(VectorOptions::new("requests_total", "requests"))
498            .unwrap()
499            .inc(&[])
500            .unwrap();
501        let profiler = Arc::new(Profiler::new());
502        let server = DevServer::new(config, metrics, profiler.clone());
503        profiler.record("database", Duration::from_millis(2));
504        server
505    }
506
507    #[actix_rt::test]
508    async fn configuration_deserialization_preserves_production_defaults() {
509        let config: DevServerConfig =
510            serde_json::from_str(r#"{"port":7070,"enable_profiling":false}"#).unwrap();
511
512        assert_eq!(config.port, 7070);
513        assert_eq!(config.health_path, "/healthz");
514        assert!(config.enable_metrics);
515        assert!(!config.enable_profiling);
516    }
517
518    #[actix_rt::test]
519    async fn serves_health_metrics_profile_and_runtime_diagnostics() {
520        let server = server(DevServerConfig::default());
521        let app =
522            test::init_service(App::new().configure(move |config| server.configure(config))).await;
523
524        for path in [
525            "/healthz",
526            "/metrics",
527            "/debug/profile",
528            "/debug/runtime",
529            "/debug/tasks",
530            "/debug/allocator",
531        ] {
532            let response =
533                test::call_service(&app, test::TestRequest::get().uri(path).to_request()).await;
534            assert_eq!(response.status(), StatusCode::OK, "{path}");
535        }
536
537        let response =
538            test::call_service(&app, test::TestRequest::get().uri("/metrics").to_request()).await;
539        let body = to_bytes(response.into_body()).await.unwrap();
540        assert!(std::str::from_utf8(&body)
541            .unwrap()
542            .contains("requests_total 1"));
543    }
544
545    #[actix_rt::test]
546    async fn disabled_optional_routes_are_not_registered() {
547        let server = server(DevServerConfig {
548            enable_metrics: false,
549            enable_profiling: false,
550            ..DevServerConfig::default()
551        });
552        let routes = server.routes();
553        let app =
554            test::init_service(App::new().configure(move |config| server.configure(config))).await;
555
556        assert!(!routes.contains(&"/metrics".to_owned()));
557        for path in ["/metrics", "/debug/profile"] {
558            let response =
559                test::call_service(&app, test::TestRequest::get().uri(path).to_request()).await;
560            assert_eq!(response.status(), StatusCode::NOT_FOUND, "{path}");
561        }
562    }
563
564    #[actix_rt::test]
565    async fn aggregates_dependency_health_without_handler_polling() {
566        let health = HealthRegistry::new();
567        health.set("users-rpc", false);
568        let server = server(DevServerConfig::default()).with_health_registry(health.clone());
569        let app =
570            test::init_service(App::new().configure(move |config| server.configure(config))).await;
571
572        let response =
573            test::call_service(&app, test::TestRequest::get().uri("/healthz").to_request()).await;
574        assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
575        let body = to_bytes(response.into_body()).await.unwrap();
576        assert_eq!(&body[..], b"NOT READY: users-rpc");
577
578        health.set("users-rpc", true);
579        let response =
580            test::call_service(&app, test::TestRequest::get().uri("/healthz").to_request()).await;
581        assert_eq!(response.status(), StatusCode::OK);
582    }
583
584    #[actix_rt::test]
585    async fn bearer_authentication_protects_every_diagnostic_route() {
586        let server = server(DevServerConfig {
587            auth_token: Some("diagnostics-secret".to_owned()),
588            ..DevServerConfig::default()
589        });
590        let app =
591            test::init_service(App::new().configure(move |config| server.configure(config))).await;
592
593        for path in ["/", "/healthz", "/metrics", "/debug/tasks"] {
594            let response =
595                test::call_service(&app, test::TestRequest::get().uri(path).to_request()).await;
596            assert_eq!(response.status(), StatusCode::UNAUTHORIZED, "{path}");
597
598            let response = test::call_service(
599                &app,
600                test::TestRequest::get()
601                    .uri(path)
602                    .insert_header((header::AUTHORIZATION, "Bearer diagnostics-secret"))
603                    .to_request(),
604            )
605            .await;
606            assert_eq!(response.status(), StatusCode::OK, "{path}");
607        }
608    }
609
610    #[actix_rt::test]
611    async fn validates_private_binding_and_sampling_bounds() {
612        let private = DevServerConfig {
613            host: "10.2.3.4".to_owned(),
614            private_only: true,
615            ..DevServerConfig::default()
616        };
617        assert!(private.validate().is_ok());
618
619        let public = DevServerConfig {
620            host: "8.8.8.8".to_owned(),
621            private_only: true,
622            ..DevServerConfig::default()
623        };
624        assert!(public.validate().is_err());
625
626        let wildcard = DevServerConfig {
627            host: String::new(),
628            private_only: true,
629            ..DevServerConfig::default()
630        };
631        assert!(wildcard.validate().is_err());
632
633        let invalid_sampling = DevServerConfig {
634            sampling_seconds: 0,
635            ..DevServerConfig::default()
636        };
637        assert!(invalid_sampling.validate().is_err());
638    }
639
640    #[actix_rt::test]
641    async fn exposes_bounded_task_and_allocator_diagnostics() {
642        let server = server(DevServerConfig::default());
643        let app =
644            test::init_service(App::new().configure(move |config| server.configure(config))).await;
645
646        let tasks: serde_json::Value = test::call_and_read_body_json(
647            &app,
648            test::TestRequest::get().uri("/debug/tasks").to_request(),
649        )
650        .await;
651        assert_eq!(tasks["runtime_available"], true);
652        assert!(tasks["worker_threads"].as_u64().unwrap() >= 1);
653
654        let allocator: serde_json::Value = test::call_and_read_body_json(
655            &app,
656            test::TestRequest::get()
657                .uri("/debug/allocator")
658                .to_request(),
659        )
660        .await;
661        assert_eq!(allocator["allocator"], "system");
662        assert!(allocator["resident_set_high_water_bytes"].is_number());
663    }
664
665    #[cfg(all(feature = "sampling-profiler", unix))]
666    #[actix_rt::test]
667    async fn sampling_profiler_produces_an_svg_flamegraph() {
668        let load = tokio::task::spawn_blocking(|| {
669            let started = Instant::now();
670            while started.elapsed() < Duration::from_secs(1) {
671                std::hint::black_box(started.elapsed());
672            }
673        });
674        let profile = tokio::task::spawn_blocking(|| render_flamegraph(1, 99));
675        let (load, svg) = tokio::join!(load, profile);
676        load.unwrap();
677        let svg = svg.unwrap().unwrap();
678        let svg = std::str::from_utf8(&svg).unwrap();
679        assert!(
680            svg.contains("<svg"),
681            "unexpected flamegraph output: {svg:?}"
682        );
683        assert!(svg.contains("</svg>"));
684    }
685}