perf-sentinel-core 0.7.8

Core library for perf-sentinel: polyglot performance anti-pattern detector
Documentation
//! User-facing Scaphandre scraper configuration.

use std::collections::HashMap;
use std::time::Duration;

/// Per-service rule that picks one Scaphandre `ProcessPower` reading.
///
/// `exe_contains` is a substring matched against the `exe` label
/// (Scaphandre emits an absolute path, so a basename like `"java"` or a
/// longer fragment like `"temurin-25-jdk-amd64/bin/java"` both work).
/// `cmdline_contains` is an optional substring matched against the
/// `cmdline` label, which Scaphandre emits as argv concatenated without
/// separators (`java -jar /tmp/svc-a.jar` becomes
/// `cmdline="java-jar/tmp/svc-a.jar"`). The matcher requires both
/// substrings to be present when `cmdline_contains` is set, exactly one
/// candidate process otherwise the matcher skips that service for the
/// tick.
#[derive(Clone, Debug, serde::Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct ProcessMatcher {
    pub exe_contains: String,
    #[serde(default)]
    pub cmdline_contains: Option<String>,
}

/// User-facing configuration for the Scaphandre scraper.
///
/// Parsed from `[green.scaphandre]` in `.perf-sentinel.toml`:
///
/// ```toml
/// [green.scaphandre]
/// endpoint = "http://localhost:8080/metrics"
/// scrape_interval_secs = 5
///
/// [green.scaphandre.process_map."order-svc"]
/// exe_contains = "bin/java"
/// cmdline_contains = "order-svc.jar"
///
/// [green.scaphandre.process_map."chat-svc"]
/// exe_contains = "bin/java"
/// cmdline_contains = "chat-svc.jar"
///
/// [green.scaphandre.process_map."native-svc"]
/// exe_contains = "/opt/native-svc/bin/native-svc"
/// ```
///
/// Absent config → no scraper spawned → all services fall back to the
/// proxy model. This struct is only constructed when the user sets at
/// least an `endpoint`.
#[derive(Clone)]
pub struct ScaphandreConfig {
    /// Full URL of the Prometheus-format metrics endpoint. No TLS
    /// support is implemented; the endpoint MUST be `http://...` on
    /// localhost or a trusted host on the same network segment.
    pub endpoint: String,
    /// How often to scrape. Default `5s`. Clamped to `[1, 3600]` at
    /// config load time.
    pub scrape_interval: Duration,
    /// Maps perf-sentinel service names (from span `service.name`) to
    /// per-service `ProcessMatcher` rules. A service with no entry here
    /// falls back to the proxy model regardless of whether the Scaphandre
    /// endpoint is reachable.
    pub process_map: HashMap<String, ProcessMatcher>,
    /// Optional auth header in curl format (`"Name: Value"`) attached
    /// to every Scaphandre request. Required when the exporter sits
    /// behind a reverse proxy with basic auth or bearer-token enforcement.
    /// Stored as plain `String` (not `secrecy::SecretString`) to avoid
    /// adding a dependency. The manual `Debug` impl below redacts this
    /// field. Resolved via the `PERF_SENTINEL_SCAPHANDRE_AUTH_HEADER`
    /// environment variable with fallback to this field; env wins when
    /// both are set.
    pub auth_header: Option<String>,
}

// Manual Debug impl to redact the auth header (potentially a secret).
impl std::fmt::Debug for ScaphandreConfig {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("ScaphandreConfig")
            .field("endpoint", &self.endpoint)
            .field("scrape_interval", &self.scrape_interval)
            .field("process_map", &self.process_map)
            .field(
                "auth_header",
                &self.auth_header.as_ref().map(|_| "[REDACTED]"),
            )
            .finish()
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn sample_config() -> ScaphandreConfig {
        let mut process_map = HashMap::new();
        process_map.insert(
            "order-svc".to_string(),
            ProcessMatcher {
                exe_contains: "bin/java".to_string(),
                cmdline_contains: Some("order-svc.jar".to_string()),
            },
        );
        ScaphandreConfig {
            endpoint: "http://localhost:8080/metrics".to_string(),
            scrape_interval: Duration::from_secs(5),
            process_map,
            auth_header: Some("Authorization: Bearer super-secret-do-not-log".to_string()),
        }
    }

    #[test]
    fn debug_impl_redacts_auth_header() {
        // Regression guard against `#[derive(Debug)]` being
        // reintroduced on the struct, which would print the credential.
        let cfg = sample_config();
        crate::test_helpers::assert_debug_redacts_secret!(&cfg, "super-secret-do-not-log");
    }

    #[test]
    fn debug_impl_preserves_non_secret_fields() {
        let cfg = sample_config();
        let dbg = format!("{cfg:?}");
        assert!(dbg.contains("endpoint"));
        assert!(dbg.contains("http://localhost:8080/metrics"));
        assert!(dbg.contains("order-svc"));
        assert!(dbg.contains("bin/java"));
        assert!(dbg.contains("order-svc.jar"));
    }
}