rs-zero 0.2.8

Rust-first microservice framework inspired by go-zero engineering practices
Documentation
use std::{fs, time::Duration};

use rs_zero::core::logging::{
    ErrorAggregator, LogConfig, LogFields, LogFormat, LogSampler, LogSpanEvents, LogWriterConfig,
    RedactionConfig, RollingFileConfig, SamplingConfig, redact_text, validate_writer,
    with_scoped_tracing,
};

#[test]
fn log_config_supports_structured_json_and_fields() {
    let config = LogConfig::default()
        .with_format(LogFormat::Json)
        .with_service("billing")
        .with_writer(LogWriterConfig::Stdout);

    assert_eq!(config.format, LogFormat::Json);
    assert_eq!(config.service.as_deref(), Some("billing"));
    assert_eq!(config.filter, "info");
}

#[test]
fn log_fields_preserve_low_cardinality_context() {
    let fields = LogFields::new("billing")
        .with_transport("grpc")
        .with_route("/users/:id")
        .with_request_id("req-1")
        .with_trace_id("4bf92f3577b34da6a3ce929d0e0e4736")
        .with_span_id("00f067aa0ba902b7")
        .with_status("Ok");

    let labels = fields.as_pairs();

    assert!(labels.contains(&("service".to_string(), "billing".to_string())));
    assert!(labels.contains(&(
        "trace_id".to_string(),
        "4bf92f3577b34da6a3ce929d0e0e4736".to_string()
    )));
    assert!(!labels.iter().any(|(_, value)| value == "/users/42"));
}

#[test]
fn redaction_masks_sensitive_fields_and_values() {
    let config = RedactionConfig::default();

    let redacted = redact_text(
        "Authorization: Bearer abc.def password=my-secret api_key=12345",
        &config,
    );

    assert!(!redacted.contains("abc.def"));
    assert!(!redacted.contains("my-secret"));
    assert!(!redacted.contains("12345"));
    assert!(redacted.contains("[REDACTED]"));
}

#[test]
fn sampler_limits_repeated_logs_by_key() {
    let sampler = LogSampler::new(SamplingConfig {
        enabled: true,
        first_n: 1,
        thereafter: 3,
    });

    assert!(sampler.should_log("db-timeout"));
    assert!(!sampler.should_log("db-timeout"));
    assert!(!sampler.should_log("db-timeout"));
    assert!(sampler.should_log("db-timeout"));
}

#[test]
fn error_aggregator_groups_similar_errors() {
    let aggregator = ErrorAggregator::new();

    aggregator.record("user 42 failed with token abc123");
    aggregator.record("user 99 failed with token def456");
    let snapshot = aggregator.snapshot();

    assert_eq!(snapshot.total, 2);
    assert_eq!(snapshot.groups.len(), 1);
}

#[test]
fn rolling_file_writer_validates_path_and_rotation() {
    let temp = tempfile::tempdir().expect("tempdir");
    let path = temp.path().join("service.log");
    let writer = LogWriterConfig::RollingFile(RollingFileConfig {
        path: path.clone(),
        max_bytes: Some(128),
        max_files: 2,
        max_age: Some(Duration::from_secs(60)),
    });

    let prepared = validate_writer(&writer).expect("writer");

    assert_eq!(prepared.path.as_deref(), Some(path.as_path()));
    assert!(prepared.rotation_enabled);
}

#[test]
fn subscriber_redacts_text_and_json_outputs_before_write() {
    for format in [LogFormat::Text, LogFormat::Json] {
        let temp = tempfile::tempdir().expect("tempdir");
        let path = temp.path().join(format!("{format:?}.log"));
        let config = LogConfig::default()
            .with_format(format)
            .with_writer(LogWriterConfig::file(&path))
            .with_redaction(RedactionConfig::default());

        with_scoped_tracing(config, || {
            let span = tracing::info_span!(
                "auth_flow",
                authorization = "Bearer raw-span-token",
                cookie = "session=raw-cookie"
            );
            let _entered = span.enter();
            tracing::info!(
                token = "raw-event-token",
                password = "raw-password",
                api_key = "raw-api-key",
                error = "secret raw-error-secret",
                "Authorization: Bearer raw-message-secret"
            );
        })
        .expect("scoped tracing");

        let output = fs::read_to_string(&path).expect("log output");
        assert!(output.contains("[REDACTED]"));
        for raw in [
            "raw-span-token",
            "raw-cookie",
            "raw-event-token",
            "raw-password",
            "raw-api-key",
            "raw-error-secret",
            "raw-message-secret",
        ] {
            assert!(
                !output.contains(raw),
                "{raw} leaked in {format:?}: {output}"
            );
        }

        if format == LogFormat::Json {
            for line in output.lines().filter(|line| !line.trim().is_empty()) {
                serde_json::from_str::<serde_json::Value>(line)
                    .unwrap_or_else(|error| panic!("invalid JSON log line: {error}: {line}"));
            }
        }
    }
}

#[test]
fn rolling_file_writer_rotates_by_size_at_runtime() {
    let temp = tempfile::tempdir().expect("tempdir");
    let path = temp.path().join("service.log");
    let config = LogConfig {
        filter: "info".to_string(),
        ansi: false,
        span_events: LogSpanEvents::None,
        writer: LogWriterConfig::RollingFile(RollingFileConfig {
            path: path.clone(),
            max_bytes: Some(256),
            max_files: 2,
            max_age: None,
        }),
        ..LogConfig::default()
    };

    with_scoped_tracing(config, || {
        for index in 0..64 {
            tracing::info!(
                index,
                payload = "runtime rotation payload with enough bytes",
                "rotation-check"
            );
        }
    })
    .expect("scoped tracing");

    let rotated_one = path.with_file_name("service.log.1");
    let rotated_two = path.with_file_name("service.log.2");
    assert!(path.exists(), "active log file should exist");
    assert!(rotated_one.exists(), "first rotated file should exist");
    assert!(
        rotated_two.exists(),
        "second rotated file should be retained"
    );
    assert!(
        !path.with_file_name("service.log.3").exists(),
        "max_files should cap retained rotated files"
    );
}