Files
rustfs/crates/obs/src/telemetry/mod.rs
T
2026-03-25 14:23:58 +08:00

251 lines
10 KiB
Rust

// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Telemetry initialisation module for RustFS.
//!
//! This module is the single entry point for all observability backends.
//! Callers should use [`init_telemetry`] and keep the returned [`OtelGuard`]
//! alive for the lifetime of the application.
//!
//! ## Architecture
//!
//! The module is split into focused sub-modules:
//!
//! | Sub-module | Responsibility |
//! |--------------|---------------------------------------------------------|
//! | `guard` | [`OtelGuard`] RAII type for provider lifecycle |
//! | `filter` | `EnvFilter` construction helpers |
//! | `resource` | OpenTelemetry `Resource` builder |
//! | `local` | Local logging: stdout-only **or** rolling-file |
//! | `otel` | Full OTLP/HTTP pipeline (traces + metrics + logs) |
//!
//! ## Routing rules (evaluated in order)
//!
//! 1. **OpenTelemetry** — if any OTLP endpoint is configured, the full HTTP
//! pipeline is initialised via [`otel::init_observability_http`].
//! 2. **File logging** — if `RUSTFS_OBS_LOG_DIRECTORY` (or `log_directory` /
//! `log_dir` in config) is set to a non-empty value, rolling-file logging is
//! initialised together with an optional stdout mirror.
//! 3. **Stdout only** — default fallback; no file I/O, no remote export.
// Dial9 module - public types are re-exported at crate level
pub mod dial9;
mod filter;
mod guard;
mod local;
mod otel;
mod recorder;
mod resource;
mod rolling;
use crate::TelemetryError;
use crate::config::OtelConfig;
pub use guard::OtelGuard;
pub use recorder::Recorder;
use rustfs_config::observability::ENV_OBS_LOG_DIRECTORY;
use rustfs_config::{DEFAULT_LOG_LEVEL, ENVIRONMENT, observability::DEFAULT_OBS_ENVIRONMENT_PRODUCTION};
use rustfs_utils::get_env_opt_str;
/// Initialize the telemetry subsystem according to the provided configuration.
///
/// Evaluates three routing rules in priority order and delegates to the
/// appropriate backend:
///
/// 1. If any OTLP endpoint is set, initialises the full
/// OpenTelemetry HTTP pipeline (traces + metrics + logs).
/// 2. If a log directory is explicitly configured via the
/// `RUSTFS_OBS_LOG_DIRECTORY` environment variable, initialises
/// rolling-file logging with an optional stdout mirror.
/// 3. Otherwise, falls back to stdout-only JSON logging.
///
/// # Arguments
/// * `config` - Observability configuration, typically built from environment
/// variables via [`OtelConfig::extract_otel_config_from_env`].
///
/// # Returns
/// An [`OtelGuard`] that must be kept alive for the duration of the
/// application. Dropping it triggers ordered shutdown of all providers.
///
/// # Errors
/// Returns [`TelemetryError`] when a backend fails to initialize (e.g., cannot
/// create the log directory, or an OTLP exporter cannot connect).
pub(crate) fn init_telemetry(config: &OtelConfig) -> Result<OtelGuard, TelemetryError> {
let environment = config.environment.as_deref().unwrap_or(ENVIRONMENT);
let is_production = environment.eq_ignore_ascii_case(DEFAULT_OBS_ENVIRONMENT_PRODUCTION);
let logger_level = config.logger_level.as_deref().unwrap_or(DEFAULT_LOG_LEVEL);
// ── Rule 1: OpenTelemetry HTTP pipeline ───────────────────────────────────
// Activated when at least one OTLP endpoint is non-empty.
let has_obs = !config.endpoint.is_empty()
|| config.trace_endpoint.as_deref().map(|s| !s.is_empty()).unwrap_or(false)
|| config.metric_endpoint.as_deref().map(|s| !s.is_empty()).unwrap_or(false)
|| config.log_endpoint.as_deref().map(|s| !s.is_empty()).unwrap_or(false);
if has_obs {
return otel::init_observability_http(config, logger_level, is_production);
}
// ── Rule 2 & 3: Local logging (file or stdout) ────────────────────────────
// `init_local_logging` internally decides between file and stdout mode
// based on whether a log directory is configured.
//
// We check the environment variable here (rather than relying solely on the
// config struct) to honour dynamic overrides set after config construction.
let user_set_log_dir = get_env_opt_str(ENV_OBS_LOG_DIRECTORY);
let effective_config = if user_set_log_dir.as_deref().filter(|d| !d.is_empty()).is_some() {
// Environment variable is set: ensure the config reflects it so that
// `init_local_logging` picks up the value even if the struct was built
// before the env var was set.
std::borrow::Cow::Owned(OtelConfig {
log_directory: user_set_log_dir,
..config.clone()
})
} else {
std::borrow::Cow::Borrowed(config)
};
local::init_local_logging(&effective_config, logger_level, is_production)
}
#[cfg(test)]
mod tests {
use super::*;
use rustfs_config::observability::DEFAULT_OBS_ENVIRONMENT_PRODUCTION;
use rustfs_config::{DEFAULT_OBS_LOG_STDOUT_ENABLED, ENVIRONMENT};
#[test]
fn test_production_environment_detection() {
// Verify that case-insensitive comparison correctly identifies production.
let production_envs = ["production", "PRODUCTION", "Production"];
for env_value in production_envs {
let is_production = env_value.eq_ignore_ascii_case(DEFAULT_OBS_ENVIRONMENT_PRODUCTION);
assert!(is_production, "Should detect '{env_value}' as production environment");
}
}
#[test]
fn test_non_production_environment_detection() {
// Verify that non-production environments are not misidentified.
let non_production_envs = ["development", "test", "staging", "dev", "local"];
for env_value in non_production_envs {
let is_production = env_value.eq_ignore_ascii_case(DEFAULT_OBS_ENVIRONMENT_PRODUCTION);
assert!(!is_production, "Should not detect '{env_value}' as production environment");
}
}
#[test]
fn test_stdout_behavior_logic() {
// Validate the stdout-enable logic for different environment/config combinations.
struct TestCase {
is_production: bool,
config_use_stdout: Option<bool>,
expected_use_stdout: bool,
description: &'static str,
}
let test_cases = [
TestCase {
is_production: true,
config_use_stdout: None,
expected_use_stdout: false,
description: "Production with no config should disable stdout",
},
TestCase {
is_production: false,
config_use_stdout: None,
expected_use_stdout: DEFAULT_OBS_LOG_STDOUT_ENABLED,
description: "Non-production with no config should use default",
},
TestCase {
is_production: true,
config_use_stdout: Some(true),
expected_use_stdout: true,
description: "Production with explicit true should enable stdout",
},
TestCase {
is_production: true,
config_use_stdout: Some(false),
expected_use_stdout: false,
description: "Production with explicit false should disable stdout",
},
TestCase {
is_production: false,
config_use_stdout: Some(true),
expected_use_stdout: true,
description: "Non-production with explicit true should enable stdout",
},
];
for case in &test_cases {
let default_use_stdout = if case.is_production {
false
} else {
DEFAULT_OBS_LOG_STDOUT_ENABLED
};
let actual = case.config_use_stdout.unwrap_or(default_use_stdout);
assert_eq!(actual, case.expected_use_stdout, "Test case failed: {}", case.description);
}
}
#[test]
fn test_log_level_filter_mapping_logic() {
// Validate the log level string → tracing level mapping used in filters.
let test_cases = [
("trace", "Trace"),
("debug", "Debug"),
("info", "Info"),
("warn", "Warn"),
("warning", "Warn"),
("error", "Error"),
("off", "None"),
("invalid_level", "Info"),
];
for (input, expected) in test_cases {
let mapped = match input.to_lowercase().as_str() {
"trace" => "Trace",
"debug" => "Debug",
"info" => "Info",
"warn" | "warning" => "Warn",
"error" => "Error",
"off" => "None",
_ => "Info",
};
assert_eq!(mapped, expected, "Log level '{input}' should map to '{expected}'");
}
}
#[test]
fn test_otel_config_environment_defaults() {
// Verify that environment field defaults behave correctly.
let config = OtelConfig {
endpoint: "".to_string(),
use_stdout: None,
environment: Some("production".to_string()),
..Default::default()
};
let environment = config.environment.as_deref().unwrap_or(ENVIRONMENT);
assert_eq!(environment, "production");
let dev_config = OtelConfig {
endpoint: "".to_string(),
use_stdout: None,
environment: Some("development".to_string()),
..Default::default()
};
let dev_environment = dev_config.environment.as_deref().unwrap_or(ENVIRONMENT);
assert_eq!(dev_environment, "development");
}
}