tork-core 0.1.0

Core runtime for the Tork web framework: HTTP server, routing, dependency injection, responses, and errors, built on Hyper and Tokio.
Documentation
//! Logging configuration.
//!
//! [`LoggerConfig`] describes how the application logs: the level, the output
//! format (a colored developer console or structured JSON), whether HTTP request
//! logs are emitted, and optional file and OpenTelemetry sinks. It is a plain
//! value, so an application can build it directly or from its own settings.

use std::path::PathBuf;

/// Default log level when none is configured.
pub(crate) const DEFAULT_LEVEL: &str = "info";
/// Default service name reported in structured logs.
pub(crate) const DEFAULT_SERVICE_NAME: &str = "app";
/// Default file name prefix for the rolling file sink.
pub(crate) const DEFAULT_FILE_PREFIX: &str = "app";

/// How much structured error detail a logged record includes.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ErrorLogDetail {
    /// Log only the error's concrete type name.
    #[default]
    TypeOnly,
    /// Log the type name and the top-level error message.
    MessageOnly,
    /// Log the type name, top-level message, and the bounded `source()` chain.
    FullChain,
}

/// The console output format.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum LogFormat {
    /// Choose automatically: a pretty console when attached to a terminal,
    /// structured JSON otherwise.
    #[default]
    Auto,
    /// A colored, human-readable console line.
    Pretty,
    /// A terse single-line console format.
    Compact,
    /// One JSON object per line.
    Json,
}

/// How often a file log is rolled over.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Rotation {
    /// Never roll over; a single file grows.
    Never,
    /// Roll over hourly.
    Hourly,
    /// Roll over daily.
    #[default]
    Daily,
}

/// Configuration for a rolling file log sink.
#[derive(Debug, Clone)]
pub struct FileLogConfig {
    pub(crate) directory: PathBuf,
    pub(crate) prefix: String,
    pub(crate) rotation: Rotation,
    pub(crate) non_blocking: bool,
}

impl FileLogConfig {
    /// Creates a file sink writing into `directory`.
    pub fn new(directory: impl Into<PathBuf>) -> Self {
        Self {
            directory: directory.into(),
            prefix: DEFAULT_FILE_PREFIX.to_owned(),
            rotation: Rotation::default(),
            non_blocking: true,
        }
    }

    /// Sets the file name prefix.
    pub fn prefix(mut self, prefix: impl Into<String>) -> Self {
        self.prefix = prefix.into();
        self
    }

    /// Sets the rotation policy.
    pub fn rotation(mut self, rotation: Rotation) -> Self {
        self.rotation = rotation;
        self
    }

    /// Sets whether file writes go through a non-blocking background worker.
    pub fn non_blocking(mut self, non_blocking: bool) -> Self {
        self.non_blocking = non_blocking;
        self
    }
}

/// Configuration for OpenTelemetry trace export (effective with the `otel` feature).
// Fields are read by the OTel layer, which lands behind the `otel` feature later.
#[allow(dead_code)]
#[derive(Debug, Clone)]
pub struct TelemetryConfig {
    pub(crate) enabled: bool,
    pub(crate) otlp_endpoint: String,
    pub(crate) service_name: String,
}

impl TelemetryConfig {
    /// Creates a telemetry configuration exporting to `otlp_endpoint`.
    pub fn new(otlp_endpoint: impl Into<String>) -> Self {
        Self {
            enabled: true,
            otlp_endpoint: otlp_endpoint.into(),
            service_name: DEFAULT_SERVICE_NAME.to_owned(),
        }
    }

    /// Sets the reported service name.
    pub fn service_name(mut self, name: impl Into<String>) -> Self {
        self.service_name = name.into();
        self
    }

    /// Enables or disables export.
    pub fn enabled(mut self, enabled: bool) -> Self {
        self.enabled = enabled;
        self
    }
}

/// How the application logs.
#[derive(Debug, Clone)]
pub struct LoggerConfig {
    pub(crate) level: String,
    pub(crate) format: LogFormat,
    pub(crate) color: bool,
    pub(crate) service_name: String,
    pub(crate) error_detail: ErrorLogDetail,
    pub(crate) request_logs: bool,
    // include_source/include_thread_ids are reserved for the formatter; telemetry
    // is consumed by the OTel layer behind the `otel` feature.
    #[allow(dead_code)]
    pub(crate) include_source: bool,
    #[allow(dead_code)]
    pub(crate) include_thread_ids: bool,
    pub(crate) non_blocking: bool,
    pub(crate) file: Option<FileLogConfig>,
    #[allow(dead_code)]
    pub(crate) telemetry: Option<TelemetryConfig>,
}

impl Default for LoggerConfig {
    fn default() -> Self {
        Self {
            level: DEFAULT_LEVEL.to_owned(),
            format: LogFormat::Auto,
            color: true,
            service_name: DEFAULT_SERVICE_NAME.to_owned(),
            error_detail: ErrorLogDetail::default(),
            request_logs: true,
            include_source: false,
            include_thread_ids: false,
            non_blocking: false,
            file: None,
            telemetry: None,
        }
    }
}

impl LoggerConfig {
    /// Creates a configuration with the default settings.
    pub fn new() -> Self {
        Self::default()
    }

    /// Sets the maximum log level (`trace`/`debug`/`info`/`warn`/`error`, or any
    /// `RUST_LOG`-style directive).
    pub fn level(mut self, level: impl Into<String>) -> Self {
        self.level = level.into();
        self
    }

    /// Sets the output format.
    pub fn format(mut self, format: LogFormat) -> Self {
        self.format = format;
        self
    }

    /// Enables or disables ANSI color in the console format.
    pub fn color(mut self, color: bool) -> Self {
        self.color = color;
        self
    }

    /// Sets the service name reported in structured logs.
    pub fn service_name(mut self, name: impl Into<String>) -> Self {
        self.service_name = name.into();
        self
    }

    /// Sets how much error detail structured log records include.
    pub fn error_detail(mut self, detail: ErrorLogDetail) -> Self {
        self.error_detail = detail;
        self
    }

    /// Enables or disables the automatic HTTP request-completed log.
    pub fn request_logs(mut self, enabled: bool) -> Self {
        self.request_logs = enabled;
        self
    }

    /// Includes the source file and line in each record.
    pub fn include_source(mut self, include: bool) -> Self {
        self.include_source = include;
        self
    }

    /// Includes the thread id in each record.
    pub fn include_thread_ids(mut self, include: bool) -> Self {
        self.include_thread_ids = include;
        self
    }

    /// Writes through a non-blocking background worker.
    pub fn non_blocking(mut self, non_blocking: bool) -> Self {
        self.non_blocking = non_blocking;
        self
    }

    /// Adds a rolling file sink.
    pub fn file(mut self, file: FileLogConfig) -> Self {
        self.file = Some(file);
        self
    }

    /// Adds OpenTelemetry trace export (effective with the `otel` feature).
    pub fn telemetry(mut self, telemetry: TelemetryConfig) -> Self {
        self.telemetry = Some(telemetry);
        self
    }
}

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

    #[test]
    fn defaults_are_sensible() {
        let config = LoggerConfig::new();
        assert_eq!(config.level, "info");
        assert_eq!(config.format, LogFormat::Auto);
        assert!(config.color);
        assert_eq!(config.error_detail, ErrorLogDetail::TypeOnly);
        assert!(config.request_logs);
        assert!(config.file.is_none());
        assert!(config.telemetry.is_none());
    }

    #[test]
    fn builders_set_fields() {
        let config = LoggerConfig::new()
            .level("debug")
            .format(LogFormat::Json)
            .service_name("tork-api")
            .error_detail(ErrorLogDetail::FullChain)
            .request_logs(false)
            .include_source(true)
            .include_thread_ids(true)
            .non_blocking(true)
            .file(
                FileLogConfig::new("./logs")
                    .prefix("api")
                    .rotation(Rotation::Hourly),
            );

        assert_eq!(config.level, "debug");
        assert_eq!(config.format, LogFormat::Json);
        assert_eq!(config.service_name, "tork-api");
        assert_eq!(config.error_detail, ErrorLogDetail::FullChain);
        assert!(!config.request_logs);
        assert!(config.include_source);
        assert!(config.include_thread_ids);
        assert!(config.non_blocking);
        let file = config.file.expect("file sink");
        assert_eq!(file.prefix, "api");
        assert_eq!(file.rotation, Rotation::Hourly);
    }

    #[test]
    fn file_and_telemetry_builders_cover_all_fields() {
        let file = FileLogConfig::new("./logs")
            .prefix("svc")
            .rotation(Rotation::Never)
            .non_blocking(false);
        assert_eq!(file.directory, PathBuf::from("./logs"));
        assert_eq!(file.prefix, "svc");
        assert_eq!(file.rotation, Rotation::Never);
        assert!(!file.non_blocking);

        let telemetry = TelemetryConfig::new("http://localhost:4317")
            .service_name("tork-api")
            .enabled(false);
        assert!(!telemetry.enabled);
        assert_eq!(telemetry.otlp_endpoint, "http://localhost:4317");
        assert_eq!(telemetry.service_name, "tork-api");
    }

    #[test]
    fn log_format_and_rotation_deserialize_from_lowercase() {
        let format: LogFormat = serde_json::from_str("\"json\"").unwrap();
        assert_eq!(format, LogFormat::Json);
        let format: LogFormat = serde_json::from_str("\"auto\"").unwrap();
        assert_eq!(format, LogFormat::Auto);
        let format: LogFormat = serde_json::from_str("\"pretty\"").unwrap();
        assert_eq!(format, LogFormat::Pretty);
        let format: LogFormat = serde_json::from_str("\"compact\"").unwrap();
        assert_eq!(format, LogFormat::Compact);

        let rotation: Rotation = serde_json::from_str("\"never\"").unwrap();
        assert_eq!(rotation, Rotation::Never);
        let rotation: Rotation = serde_json::from_str("\"hourly\"").unwrap();
        assert_eq!(rotation, Rotation::Hourly);
        let rotation: Rotation = serde_json::from_str("\"daily\"").unwrap();
        assert_eq!(rotation, Rotation::Daily);
    }
}