feat(obs): add advanced log management configuration (#2016)

Co-authored-by: houseme <housemecn@gmail.com>
Co-authored-by: 安正超 <anzhengchao@gmail.com>
Co-authored-by: 唐小鸭 <tangtang1251@qq.com>
This commit is contained in:
heihutu
2026-03-01 03:23:48 +08:00
committed by GitHub
parent e7466eb1cc
commit 2c01b8c49d
26 changed files with 2560 additions and 1305 deletions
+94
View File
@@ -0,0 +1,94 @@
// 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.
//! Log filtering utilities for tracing subscribers.
//!
//! This module provides helper functions for building `EnvFilter` instances
//! used across different logging backends (stdout, file, OpenTelemetry).
use smallvec::SmallVec;
use tracing_subscriber::EnvFilter;
/// Build an `EnvFilter` from the given log level string.
///
/// If the `RUST_LOG` environment variable is set, it takes precedence over the
/// provided `logger_level`. For non-verbose levels (`info`, `warn`, `error`),
/// noisy internal crates (`hyper`, `tonic`, `h2`, `reqwest`, `tower`) are
/// automatically silenced to reduce log noise.
///
/// # Arguments
/// * `logger_level` - The desired log level string (e.g., `"info"`, `"debug"`).
/// * `default_level` - An optional override that replaces `logger_level` as the
/// base directive; useful when the caller wants to force a specific level
/// regardless of what is stored in config.
///
/// # Returns
/// A configured `EnvFilter` ready to be attached to a `tracing_subscriber` registry.
pub(super) fn build_env_filter(logger_level: &str, default_level: Option<&str>) -> EnvFilter {
let level = default_level.unwrap_or(logger_level);
let mut filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(level));
// Suppress chatty infrastructure crates unless the operator explicitly
// requests trace/debug output.
if !matches!(logger_level, "trace" | "debug") {
let directives: SmallVec<[&str; 5]> = smallvec::smallvec!["hyper", "tonic", "h2", "reqwest", "tower"];
for directive in directives {
filter = filter.add_directive(format!("{directive}=off").parse().unwrap());
}
}
filter
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_build_env_filter_default_level_overrides() {
// Ensure that providing a default_level uses it instead of logger_level.
let filter = build_env_filter("debug", Some("error"));
// The Debug output uses `LevelFilter::ERROR` for the error level directive.
let dbg = format!("{filter:?}");
assert!(
dbg.contains("LevelFilter::ERROR"),
"Expected 'LevelFilter::ERROR' in filter debug output: {dbg}"
);
}
#[test]
fn test_build_env_filter_suppresses_noisy_crates() {
// For info level, hyper/tonic/etc. should be suppressed with OFF.
let filter = build_env_filter("info", None);
let dbg = format!("{filter:?}");
// The Debug output uses `LevelFilter::OFF` for suppressed crates.
assert!(
dbg.contains("LevelFilter::OFF"),
"Expected 'LevelFilter::OFF' suppression directives in filter: {dbg}"
);
}
#[test]
fn test_build_env_filter_debug_no_suppression() {
// For debug level, our code does NOT inject any OFF directives.
let filter = build_env_filter("debug", None);
let dbg = format!("{filter:?}");
// Verify the filter builds without panicking and contains the debug level.
assert!(!dbg.is_empty());
assert!(
dbg.contains("LevelFilter::DEBUG"),
"Expected 'LevelFilter::DEBUG' in filter debug output: {dbg}"
);
}
}
+103
View File
@@ -0,0 +1,103 @@
// 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.
//! RAII guard for OpenTelemetry provider lifecycle management.
//!
//! [`OtelGuard`] holds all runtime resources created during telemetry
//! initialisation. Dropping it triggers an ordered shutdown:
//!
//! 1. Tracer provider — flushes pending spans.
//! 2. Meter provider — flushes pending metrics.
//! 3. Logger provider — flushes pending log records.
//! 4. Cleanup task — aborted to prevent lingering background work.
//! 5. Tracing worker guard — flushes buffered log lines written by
//! `tracing_appender`.
use opentelemetry_sdk::{logs::SdkLoggerProvider, metrics::SdkMeterProvider, trace::SdkTracerProvider};
/// RAII guard that owns all active OpenTelemetry providers and the
/// `tracing_appender` worker guard.
///
/// Construct this via the `init_*` functions in [`crate::telemetry`] rather
/// than directly. The guard must be kept alive for the entire duration of the
/// application — once dropped, all telemetry pipelines are shut down.
pub struct OtelGuard {
/// Optional tracer provider for distributed tracing.
pub(crate) tracer_provider: Option<SdkTracerProvider>,
/// Optional meter provider for metrics collection.
pub(crate) meter_provider: Option<SdkMeterProvider>,
/// Optional logger provider for OTLP log export.
pub(crate) logger_provider: Option<SdkLoggerProvider>,
/// Worker guard that keeps the non-blocking `tracing_appender` thread
/// alive. Dropping it blocks until all buffered records are flushed.
pub(crate) tracing_guard: Option<tracing_appender::non_blocking::WorkerGuard>,
/// Optional guard for stdout logging; kept separate to allow independent flushing and shutdown.
pub(crate) stdout_guard: Option<tracing_appender::non_blocking::WorkerGuard>,
/// Handle to the background log-cleanup task; aborted on drop.
pub(crate) cleanup_handle: Option<tokio::task::JoinHandle<()>>,
}
impl std::fmt::Debug for OtelGuard {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("OtelGuard")
.field("tracer_provider", &self.tracer_provider.is_some())
.field("meter_provider", &self.meter_provider.is_some())
.field("logger_provider", &self.logger_provider.is_some())
.field("tracing_guard", &self.tracing_guard.is_some())
.field("stdout_guard", &self.stdout_guard.is_some())
.field("cleanup_handle", &self.cleanup_handle.is_some())
.finish()
}
}
impl Drop for OtelGuard {
/// Shut down all telemetry providers in order.
///
/// Errors during shutdown are printed to `stderr` so they are visible even
/// after the tracing subscriber has been torn down.
fn drop(&mut self) {
if let Some(provider) = self.tracer_provider.take()
&& let Err(err) = provider.shutdown()
{
eprintln!("Tracer shutdown error: {err:?}");
}
if let Some(provider) = self.meter_provider.take()
&& let Err(err) = provider.shutdown()
{
eprintln!("Meter shutdown error: {err:?}");
}
if let Some(provider) = self.logger_provider.take()
&& let Err(err) = provider.shutdown()
{
eprintln!("Logger shutdown error: {err:?}");
}
if let Some(handle) = self.cleanup_handle.take() {
handle.abort();
eprintln!("Log cleanup task stopped");
}
if let Some(guard) = self.tracing_guard.take() {
drop(guard);
eprintln!("Tracing guard dropped, flushing logs.");
}
if let Some(guard) = self.stdout_guard.take() {
drop(guard);
eprintln!("Stdout guard dropped, flushing logs.");
}
}
}
+385
View File
@@ -0,0 +1,385 @@
// 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.
//! Local logging backend: stdout-only or file-rolling with optional stdout mirror.
//!
//! # Behaviour
//!
//! | Condition | Result |
//! |----------------------------------|----------------------------------------------|
//! | No log directory configured | JSON logs written to **stdout only** |
//! | Log directory configured | JSON logs written to **rolling file**; |
//! | | stdout mirror enabled when `log_stdout_enabled` |
//! | | is `true` or environment is non-production |
//!
//! The function [`init_local_logging`] is the single entry point for both
//! cases; callers do **not** need to distinguish between stdout and file modes.
use crate::TelemetryError;
use crate::config::OtelConfig;
use crate::global::OBSERVABILITY_METRIC_ENABLED;
use crate::log_cleanup::LogCleaner;
use crate::telemetry::filter::build_env_filter;
use metrics::counter;
use rustfs_config::observability::{
DEFAULT_OBS_LOG_CLEANUP_INTERVAL_SECONDS, DEFAULT_OBS_LOG_COMPRESS_OLD_FILES, DEFAULT_OBS_LOG_COMPRESSED_FILE_RETENTION_DAYS,
DEFAULT_OBS_LOG_DELETE_EMPTY_FILES, DEFAULT_OBS_LOG_DRY_RUN, DEFAULT_OBS_LOG_GZIP_COMPRESSION_LEVEL,
DEFAULT_OBS_LOG_MAX_SINGLE_FILE_SIZE_BYTES, DEFAULT_OBS_LOG_MAX_TOTAL_SIZE_BYTES, DEFAULT_OBS_LOG_MIN_FILE_AGE_SECONDS,
};
use rustfs_config::{APP_NAME, DEFAULT_LOG_KEEP_FILES, DEFAULT_LOG_ROTATION_TIME, DEFAULT_OBS_LOG_STDOUT_ENABLED};
use std::{fs, io::IsTerminal, time::Duration};
use tracing::info;
use tracing_error::ErrorLayer;
use tracing_subscriber::{
fmt::{format::FmtSpan, time::LocalTime},
layer::SubscriberExt,
util::SubscriberInitExt,
};
use super::guard::OtelGuard;
/// Initialize local logging (stdout-only or file-rolling).
///
/// When `log_directory` is empty or `None` in the config the function sets up
/// a non-blocking JSON subscriber that writes to **stdout** and returns
/// immediately — no file I/O, no cleanup task.
///
/// When a log directory is provided the function additionally:
/// 1. Creates the directory (including on Unix, enforces `0755` permissions).
/// 2. Attaches a rolling-file appender (daily or hourly based on
/// `log_rotation_time`).
/// 3. Optionally mirrors output to stdout based on `log_stdout_enabled`.
/// 4. Spawns a background cleanup task that periodically removes or compresses
/// old log files according to the cleanup configuration in [`OtelConfig`].
///
/// # Arguments
/// * `config` - Observability configuration, fully populated from environment variables.
/// * `logger_level` - Effective log level string (e.g., `"info"`).
/// * `is_production` - Whether the runtime environment is production; controls
/// span verbosity and stdout mirroring defaults.
///
/// # Returns
/// An [`OtelGuard`] that keeps the `tracing_appender` worker alive and holds
/// a handle to the cleanup task (if started). Dropping the guard flushes
/// in-flight logs and stops the cleanup task.
///
/// # Errors
/// Returns [`TelemetryError`] if the log directory cannot be created or its
/// permissions cannot be set (Unix only).
pub(super) fn init_local_logging(
config: &OtelConfig,
logger_level: &str,
is_production: bool,
) -> Result<OtelGuard, TelemetryError> {
// Determine the effective log directory. An absent or empty value means
// stdout-only mode: we skip file setup entirely.
let log_dir_str = config.log_directory.as_deref().filter(|s| !s.is_empty());
if let Some(log_directory) = log_dir_str {
init_file_logging_internal(config, log_directory, logger_level, is_production)
} else {
Ok(init_stdout_only(config, logger_level, is_production))
}
}
// ─── Stdout-only ─────────────────────────────────────────────────────────────
/// Set up a non-blocking stdout JSON subscriber with no file I/O.
///
/// Used when no log directory has been configured. The subscriber formats
/// every log record as a JSON line, including RFC-3339 timestamps, thread
/// identifiers, file/line information, and span context.
///
/// # Arguments
/// * `_config` - Unused at the moment; reserved for future configuration.
/// * `logger_level` - Effective log level string.
/// * `is_production` - Controls span event verbosity.
fn init_stdout_only(_config: &OtelConfig, logger_level: &str, is_production: bool) -> OtelGuard {
let env_filter = build_env_filter(logger_level, None);
let (nb, guard) = tracing_appender::non_blocking(std::io::stdout());
let fmt_layer = tracing_subscriber::fmt::layer()
.with_timer(LocalTime::rfc_3339())
.with_target(true)
.with_ansi(std::io::stdout().is_terminal())
.with_thread_names(true)
.with_thread_ids(true)
.with_file(true)
.with_line_number(true)
.with_writer(nb)
.json()
.with_current_span(true)
.with_span_list(true)
.with_span_events(if is_production { FmtSpan::CLOSE } else { FmtSpan::FULL });
tracing_subscriber::registry()
.with(env_filter)
.with(ErrorLayer::default())
.with(fmt_layer)
.init();
OBSERVABILITY_METRIC_ENABLED.set(false).ok();
counter!("rustfs.start.total").increment(1);
info!("Init stdout logging (level: {})", logger_level);
OtelGuard {
tracer_provider: None,
meter_provider: None,
logger_provider: None,
tracing_guard: Some(guard),
stdout_guard: None,
cleanup_handle: None,
}
}
// ─── File-rolling ─────────────────────────────────────────────────────────────
/// Internal implementation for file-based rolling log setup.
///
/// Called by [`init_local_logging`] when a log directory is present.
/// Handles directory creation, permission enforcement (Unix), file appender
/// setup, optional stdout mirror, and log-cleanup task spawning.
fn init_file_logging_internal(
config: &OtelConfig,
log_directory: &str,
logger_level: &str,
is_production: bool,
) -> Result<OtelGuard, TelemetryError> {
let service_name = config.service_name.as_deref().unwrap_or(APP_NAME);
let log_filename = config.log_filename.as_deref().unwrap_or(service_name);
let keep_files = config.log_keep_files.unwrap_or(DEFAULT_LOG_KEEP_FILES);
// ── 1. Ensure the log directory exists ───────────────────────────────────
if let Err(e) = fs::create_dir_all(log_directory) {
return Err(TelemetryError::Io(e.to_string()));
}
// ── 2. Enforce directory permissions (Unix only) ─────────────────────────
#[cfg(unix)]
ensure_dir_permissions(log_directory)?;
// ── 3. Choose rotation strategy ──────────────────────────────────────────
// `log_rotation_time` drives the rolling-appender rotation period.
let rotation = config
.log_rotation_time
.as_deref()
.unwrap_or(DEFAULT_LOG_ROTATION_TIME)
.to_lowercase();
use tracing_appender::rolling::{RollingFileAppender, Rotation};
let file_appender = {
let rotation = match rotation.as_str() {
"minutely" => Rotation::MINUTELY,
"hourly" => Rotation::HOURLY,
_ => Rotation::DAILY,
};
RollingFileAppender::builder()
.rotation(rotation)
.filename_suffix(log_filename)
.max_log_files(keep_files)
.build(log_directory)
.expect("failed to initialize rolling file appender")
};
let (non_blocking, guard) = tracing_appender::non_blocking(file_appender);
// ── 4. Build subscriber layers ────────────────────────────────────────────
let env_filter = build_env_filter(logger_level, None);
let span_events = if is_production { FmtSpan::CLOSE } else { FmtSpan::FULL };
// File layer writes JSON without ANSI codes.
let file_layer = tracing_subscriber::fmt::layer()
.with_timer(LocalTime::rfc_3339())
.with_target(true)
.with_ansi(false)
.with_thread_names(true)
.with_thread_ids(true)
.with_file(true)
.with_line_number(true)
.with_writer(non_blocking)
.json()
.with_current_span(true)
.with_span_list(true)
.with_span_events(span_events.clone());
// Optional stdout mirror: enabled explicitly via `log_stdout_enabled`, or
// unconditionally in non-production environments.
let (stdout_layer, stdout_guard) = if config.log_stdout_enabled.unwrap_or(DEFAULT_OBS_LOG_STDOUT_ENABLED) || !is_production {
let (stdout_nb, stdout_guard) = tracing_appender::non_blocking(std::io::stdout());
let enable_color = std::io::stdout().is_terminal();
(
Some(
tracing_subscriber::fmt::layer()
.with_timer(LocalTime::rfc_3339())
.with_target(true)
.with_ansi(enable_color)
.with_thread_names(true)
.with_thread_ids(true)
.with_file(true)
.with_line_number(true)
.with_writer(stdout_nb) // .json()
// .with_current_span(true)
// .with_span_list(true)
.with_span_events(span_events),
),
Some(stdout_guard),
)
} else {
(None, None)
};
tracing_subscriber::registry()
.with(env_filter)
.with(ErrorLayer::default())
.with(file_layer)
.with(stdout_layer)
.init();
OBSERVABILITY_METRIC_ENABLED.set(false).ok();
// ── 5. Start background cleanup task ─────────────────────────────────────
let cleanup_handle = spawn_cleanup_task(config, log_directory, log_filename, keep_files);
info!(
"Init file logging at '{}', rotation: {}, keep {} files",
log_directory, rotation, keep_files
);
Ok(OtelGuard {
tracer_provider: None,
meter_provider: None,
logger_provider: None,
tracing_guard: Some(guard),
stdout_guard,
cleanup_handle: Some(cleanup_handle),
})
}
// ─── Directory permissions (Unix) ─────────────────────────────────────────────
/// Ensure the log directory has at most `0755` permissions (Unix only).
///
/// Tightens permissions to `0755` if the directory is more permissive.
/// This prevents world-writable log directories from being a security hazard.
/// No-ops if permissions are already `0755` or stricter.
#[cfg(unix)]
fn ensure_dir_permissions(log_directory: &str) -> Result<(), TelemetryError> {
use std::fs::Permissions;
use std::os::unix::fs::PermissionsExt;
let desired: u32 = 0o755;
match fs::metadata(log_directory) {
Ok(meta) => {
let current = meta.permissions().mode() & 0o777;
// Only tighten to 0755 if existing permissions are looser than target.
if (current & !desired) != 0 {
if let Err(e) = fs::set_permissions(log_directory, Permissions::from_mode(desired)) {
return Err(TelemetryError::SetPermissions(format!(
"dir='{log_directory}', want={desired:#o}, have={current:#o}, err={e}"
)));
}
// Second verification pass to confirm the change took effect.
if let Ok(meta2) = fs::metadata(log_directory) {
let after = meta2.permissions().mode() & 0o777;
if after != desired {
return Err(TelemetryError::SetPermissions(format!(
"dir='{log_directory}', want={desired:#o}, after={after:#o}"
)));
}
}
}
Ok(())
}
Err(e) => Err(TelemetryError::Io(format!("stat '{log_directory}' failed: {e}"))),
}
}
// ─── Cleanup task ─────────────────────────────────────────────────────────────
/// Spawn a background task that periodically cleans up old log files.
///
/// All cleanup parameters are derived from [`OtelConfig`] fields, with
/// sensible defaults when fields are absent. The task runs on the current
/// Tokio runtime and should be aborted (via the returned `JoinHandle`) when
/// the application shuts down.
///
/// # Arguments
/// * `config` - Observability config containing cleanup parameters.
/// * `log_directory` - Directory path of the rolling log files.
/// * `log_filename` - Base filename (used as the file prefix for matching).
/// * `keep_files` - Legacy keep-files count; used as fallback when the new
/// `log_keep_count` field is absent.
///
/// # Returns
/// A [`tokio::task::JoinHandle`] for the spawned cleanup loop.
fn spawn_cleanup_task(
config: &OtelConfig,
log_directory: &str,
log_filename: &str,
keep_files: usize,
) -> tokio::task::JoinHandle<()> {
let log_dir = std::path::PathBuf::from(log_directory);
let file_prefix = config.log_filename.as_deref().unwrap_or(log_filename).to_string();
let keep_count = config.log_keep_count.unwrap_or(keep_files);
let max_total_size = config
.log_max_total_size_bytes
.unwrap_or(DEFAULT_OBS_LOG_MAX_TOTAL_SIZE_BYTES * keep_count as u64);
let max_single_file_size = config
.log_max_single_file_size_bytes
.unwrap_or(DEFAULT_OBS_LOG_MAX_SINGLE_FILE_SIZE_BYTES);
let compress = config.log_compress_old_files.unwrap_or(DEFAULT_OBS_LOG_COMPRESS_OLD_FILES);
let gzip_level = config
.log_gzip_compression_level
.unwrap_or(DEFAULT_OBS_LOG_GZIP_COMPRESSION_LEVEL);
let retention_days = config
.log_compressed_file_retention_days
.unwrap_or(DEFAULT_OBS_LOG_COMPRESSED_FILE_RETENTION_DAYS);
let exclude_patterns = config
.log_exclude_patterns
.as_deref()
.map(|s| s.split(',').map(|p| p.trim().to_string()).collect())
.unwrap_or_default();
let delete_empty = config.log_delete_empty_files.unwrap_or(DEFAULT_OBS_LOG_DELETE_EMPTY_FILES);
let min_age = config
.log_min_file_age_seconds
.unwrap_or(DEFAULT_OBS_LOG_MIN_FILE_AGE_SECONDS);
let dry_run = config.log_dry_run.unwrap_or(DEFAULT_OBS_LOG_DRY_RUN);
let cleanup_interval = config
.log_cleanup_interval_seconds
.unwrap_or(DEFAULT_OBS_LOG_CLEANUP_INTERVAL_SECONDS);
let cleaner = LogCleaner::new(
log_dir,
file_prefix,
keep_count,
max_total_size,
max_single_file_size,
compress,
gzip_level,
retention_days,
exclude_patterns,
delete_empty,
min_age,
dry_run,
);
tokio::spawn(async move {
let mut interval = tokio::time::interval(Duration::from_secs(cleanup_interval));
loop {
interval.tick().await;
if let Err(e) = cleaner.cleanup() {
tracing::warn!("Log cleanup failed: {}", e);
}
}
})
}
+243
View File
@@ -0,0 +1,243 @@
// 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.
mod filter;
mod guard;
mod local;
mod otel;
mod recorder;
mod resource;
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 initialise (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 rustfs_config::observability::DEFAULT_OBS_ENVIRONMENT_PRODUCTION;
use rustfs_config::{ENVIRONMENT, USE_STDOUT};
#[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: USE_STDOUT,
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 { USE_STDOUT };
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.
use crate::config::OtelConfig;
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");
}
}
+317
View File
@@ -0,0 +1,317 @@
// 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.
//! OpenTelemetry HTTP exporter initialisation.
//!
//! This module sets up full OTLP/HTTP pipelines for:
//! - **Traces** via [`opentelemetry_otlp::SpanExporter`]
//! - **Metrics** via [`opentelemetry_otlp::MetricExporter`]
//! - **Logs** via [`opentelemetry_otlp::LogExporter`]
//!
//! Each signal has a dedicated endpoint field in [`OtelConfig`]. When a
//! per-signal endpoint is absent, the function falls back to appending the
//! standard OTLP path suffix to the root `endpoint` field:
//!
//! | Signal | Fallback path |
//! |---------|-----------------|
//! | Traces | `/v1/traces` |
//! | Metrics | `/v1/metrics` |
//! | Logs | `/v1/logs` |
//!
//! All exporters use **HTTP binary** (Protobuf) encoding with **gzip**
//! compression for efficiency over the wire.
use crate::TelemetryError;
use crate::config::OtelConfig;
use crate::global::OBSERVABILITY_METRIC_ENABLED;
use crate::telemetry::filter::build_env_filter;
use crate::telemetry::guard::OtelGuard;
use crate::telemetry::recorder::Recorder;
use crate::telemetry::resource::build_resource;
use metrics::counter;
use opentelemetry::{global, trace::TracerProvider};
use opentelemetry_appender_tracing::layer::OpenTelemetryTracingBridge;
use opentelemetry_otlp::{Compression, Protocol, WithExportConfig, WithHttpConfig};
use opentelemetry_sdk::propagation::TraceContextPropagator;
use opentelemetry_sdk::{
logs::SdkLoggerProvider,
metrics::{PeriodicReader, SdkMeterProvider},
trace::{RandomIdGenerator, Sampler, SdkTracerProvider},
};
use rustfs_config::{
APP_NAME, DEFAULT_OBS_LOG_STDOUT_ENABLED, DEFAULT_OBS_LOGS_EXPORT_ENABLED, DEFAULT_OBS_METRICS_EXPORT_ENABLED,
DEFAULT_OBS_TRACES_EXPORT_ENABLED, METER_INTERVAL, SAMPLE_RATIO,
};
use std::{io::IsTerminal, time::Duration};
use tracing::info;
use tracing_error::ErrorLayer;
use tracing_opentelemetry::{MetricsLayer, OpenTelemetryLayer};
use tracing_subscriber::{
Layer,
fmt::{format::FmtSpan, time::LocalTime},
layer::SubscriberExt,
util::SubscriberInitExt,
};
/// Initialize the full OpenTelemetry HTTP pipeline (traces + metrics + logs).
///
/// This function is invoked when at least one OTLP endpoint has been
/// configured. It creates exporters, wires them into SDK providers, installs
/// a global tracer/meter, and builds a `tracing_subscriber` registry that
/// bridges Rust's `tracing` macros to the OTLP pipelines.
///
/// # Arguments
/// * `config` - Fully populated observability configuration.
/// * `logger_level` - Effective log level string (e.g., `"info"`).
/// * `is_production` - Controls span verbosity and stdout layer defaults.
///
/// # Returns
/// An [`OtelGuard`] owning all created providers. Dropping it triggers an
/// ordered shutdown and flushes all pending telemetry data.
///
/// # Errors
/// Returns [`TelemetryError`] if any exporter or provider fails to build.
///
/// # Note
/// This function is intentionally kept unchanged from the pre-refactor
/// implementation to preserve existing OTLP behaviour.
pub(super) fn init_observability_http(
config: &OtelConfig,
logger_level: &str,
is_production: bool,
) -> Result<OtelGuard, TelemetryError> {
// ── Resource & sampling ──────────────────────────────────────────────────
let res = build_resource(config);
let service_name = config.service_name.as_deref().unwrap_or(APP_NAME).to_owned();
let use_stdout = config.use_stdout.unwrap_or(!is_production);
let sample_ratio = config.sample_ratio.unwrap_or(SAMPLE_RATIO);
let sampler = if (0.0..1.0).contains(&sample_ratio) {
Sampler::TraceIdRatioBased(sample_ratio)
} else {
Sampler::AlwaysOn
};
// ── Endpoint resolution ───────────────────────────────────────────────────
// Each signal may have a dedicated endpoint; if absent, fall back to the
// root endpoint with the standard OTLP path suffix appended.
let root_ep = config.endpoint.clone();
let trace_ep: String = config
.trace_endpoint
.as_deref()
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.unwrap_or_else(|| format!("{root_ep}/v1/traces"));
let metric_ep: String = config
.metric_endpoint
.as_deref()
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.unwrap_or_else(|| format!("{root_ep}/v1/metrics"));
let log_ep: String = config
.log_endpoint
.as_deref()
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.unwrap_or_else(|| format!("{root_ep}/v1/logs"));
// ── Tracer provider (HTTP) ────────────────────────────────────────────────
let tracer_provider = build_tracer_provider(&trace_ep, config, res.clone(), sampler, use_stdout)?;
// ── Meter provider (HTTP) ─────────────────────────────────────────────────
let meter_provider = build_meter_provider(&metric_ep, config, res.clone(), &service_name, use_stdout)?;
// ── Logger provider (HTTP) ────────────────────────────────────────────────
let logger_provider = build_logger_provider(&log_ep, config, res, use_stdout)?;
// ── Tracing subscriber registry ───────────────────────────────────────────
// Build an optional stdout formatting layer. When `log_stdout_enabled` is
// false the field is `None` and tracing-subscriber will skip it.
let fmt_layer_opt = if config.log_stdout_enabled.unwrap_or(DEFAULT_OBS_LOG_STDOUT_ENABLED) {
let enable_color = std::io::stdout().is_terminal();
let span_event = if is_production { FmtSpan::CLOSE } else { FmtSpan::FULL };
let layer = tracing_subscriber::fmt::layer()
.with_timer(LocalTime::rfc_3339())
.with_target(true)
.with_ansi(enable_color)
.with_thread_names(true)
.with_thread_ids(true)
.with_file(true)
.with_line_number(true)
.json()
.with_current_span(true)
.with_span_list(true)
.with_span_events(span_event)
.with_filter(build_env_filter(logger_level, None));
Some(layer)
} else {
None
};
let filter = build_env_filter(logger_level, None);
let otel_bridge = logger_provider
.as_ref()
.map(|p| OpenTelemetryTracingBridge::new(p).with_filter(build_env_filter(logger_level, None)));
let tracer_layer = tracer_provider
.as_ref()
.map(|p| OpenTelemetryLayer::new(p.tracer(service_name.to_string())));
let metrics_layer = meter_provider.as_ref().map(|p| MetricsLayer::new(p.clone()));
tracing_subscriber::registry()
.with(filter)
.with(ErrorLayer::default())
.with(fmt_layer_opt)
.with(tracer_layer)
.with(otel_bridge)
.with(metrics_layer)
.init();
counter!("rustfs.start.total").increment(1);
info!(
"Init observability (HTTP): trace='{}', metric='{}', log='{}'",
trace_ep, metric_ep, log_ep
);
Ok(OtelGuard {
tracer_provider,
meter_provider,
logger_provider,
tracing_guard: None,
stdout_guard: None,
cleanup_handle: None,
})
}
// ─── Private builder helpers ──────────────────────────────────────────────────
/// Build an optional [`SdkTracerProvider`] for the given trace endpoint.
///
/// Returns `None` when the endpoint is empty or trace export is disabled.
fn build_tracer_provider(
trace_ep: &str,
config: &OtelConfig,
res: opentelemetry_sdk::Resource,
sampler: Sampler,
use_stdout: bool,
) -> Result<Option<SdkTracerProvider>, TelemetryError> {
if trace_ep.is_empty() || !config.traces_export_enabled.unwrap_or(DEFAULT_OBS_TRACES_EXPORT_ENABLED) {
return Ok(None);
}
let exporter = opentelemetry_otlp::SpanExporter::builder()
.with_http()
.with_endpoint(trace_ep)
.with_protocol(Protocol::HttpBinary)
.with_compression(Compression::Gzip)
.build()
.map_err(|e| TelemetryError::BuildSpanExporter(e.to_string()))?;
let mut builder = SdkTracerProvider::builder()
.with_sampler(sampler)
.with_id_generator(RandomIdGenerator::default())
.with_resource(res)
.with_batch_exporter(exporter);
if use_stdout {
builder = builder.with_batch_exporter(opentelemetry_stdout::SpanExporter::default());
}
let provider = builder.build();
global::set_tracer_provider(provider.clone());
global::set_text_map_propagator(TraceContextPropagator::new());
Ok(Some(provider))
}
/// Build an optional [`SdkMeterProvider`] for the given metrics endpoint.
///
/// Returns `None` when the endpoint is empty or metric export is disabled.
fn build_meter_provider(
metric_ep: &str,
config: &OtelConfig,
res: opentelemetry_sdk::Resource,
service_name: &str,
use_stdout: bool,
) -> Result<Option<SdkMeterProvider>, TelemetryError> {
if metric_ep.is_empty() || !config.metrics_export_enabled.unwrap_or(DEFAULT_OBS_METRICS_EXPORT_ENABLED) {
return Ok(None);
}
let exporter = opentelemetry_otlp::MetricExporter::builder()
.with_http()
.with_endpoint(metric_ep)
.with_temporality(opentelemetry_sdk::metrics::Temporality::default())
.with_protocol(Protocol::HttpBinary)
.with_compression(Compression::Gzip)
.build()
.map_err(|e| TelemetryError::BuildMetricExporter(e.to_string()))?;
let meter_interval = config.meter_interval.unwrap_or(METER_INTERVAL);
let (provider, recorder) = Recorder::builder(service_name.to_string())
.with_meter_provider(|b: opentelemetry_sdk::metrics::MeterProviderBuilder| {
let b = b.with_resource(res).with_reader(
PeriodicReader::builder(exporter)
.with_interval(Duration::from_secs(meter_interval))
.build(),
);
if use_stdout {
b.with_reader(create_periodic_reader(meter_interval))
} else {
b
}
})
.build();
global::set_meter_provider(provider.clone() as SdkMeterProvider);
metrics::set_global_recorder(recorder).map_err(|e| TelemetryError::InstallMetricsRecorder(e.to_string()))?;
OBSERVABILITY_METRIC_ENABLED.set(true).ok();
Ok(Some(provider))
}
/// Build an optional [`SdkLoggerProvider`] for the given log endpoint.
///
/// Returns `None` when the endpoint is empty or log export is disabled.
fn build_logger_provider(
log_ep: &str,
config: &OtelConfig,
res: opentelemetry_sdk::Resource,
use_stdout: bool,
) -> Result<Option<SdkLoggerProvider>, TelemetryError> {
if log_ep.is_empty() || !config.logs_export_enabled.unwrap_or(DEFAULT_OBS_LOGS_EXPORT_ENABLED) {
return Ok(None);
}
let exporter = opentelemetry_otlp::LogExporter::builder()
.with_http()
.with_endpoint(log_ep)
.with_protocol(Protocol::HttpBinary)
.with_compression(Compression::Gzip)
.build()
.map_err(|e| TelemetryError::BuildLogExporter(e.to_string()))?;
let mut builder = SdkLoggerProvider::builder().with_resource(res);
builder = builder.with_batch_exporter(exporter);
if use_stdout {
builder = builder.with_batch_exporter(opentelemetry_stdout::LogExporter::default());
}
Ok(Some(builder.build()))
}
/// Create a stdout periodic metrics reader for the given interval.
fn create_periodic_reader(interval: u64) -> PeriodicReader<opentelemetry_stdout::MetricExporter> {
PeriodicReader::builder(opentelemetry_stdout::MetricExporter::default())
.with_interval(Duration::from_secs(interval))
.build()
}
+472
View File
@@ -0,0 +1,472 @@
// 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.
use crate::GlobalError;
use metrics::{Counter, CounterFn, Gauge, GaugeFn, Histogram, HistogramFn, Key, KeyName, Metadata, SharedString, Unit};
use opentelemetry::{
InstrumentationScope, InstrumentationScopeBuilder, KeyValue, global,
metrics::{Meter, MeterProvider},
};
use opentelemetry_sdk::metrics::{MeterProviderBuilder, SdkMeterProvider};
use std::{
borrow::Cow,
collections::HashMap,
ops::Deref,
sync::{
Arc, Mutex, RwLock,
atomic::{AtomicU64, Ordering},
},
};
use tracing::error;
macro_rules! configure_builder {
($builder:expr, $metadata:expr) => {{
let mut builder = $builder;
if let Some(metadata) = $metadata {
if let Some(unit) = metadata.unit {
builder = builder.with_unit(unit.as_canonical_label());
}
builder = builder.with_description(metadata.description.to_string());
}
builder
}};
}
/// A builder for constructing a [`Recorder`].
#[derive(Debug)]
pub struct Builder {
builder: MeterProviderBuilder,
scope: InstrumentationScopeBuilder,
}
impl Builder {
/// Runs the closure (`f`) to modify the [`MeterProviderBuilder`] to build a
/// [`MeterProvider`](MeterProvider).
pub fn with_meter_provider(mut self, f: impl FnOnce(MeterProviderBuilder) -> MeterProviderBuilder) -> Self {
self.builder = f(self.builder);
self
}
/// Modify the [`InstrumentationScope`] to provide additional metadata from the
/// closure (`f`).
pub fn with_instrumentation_scope(
mut self,
f: impl FnOnce(InstrumentationScopeBuilder) -> InstrumentationScopeBuilder,
) -> Self {
self.scope = f(self.scope);
self
}
/// Consumes the builder and builds a new [`Recorder`] and returns
/// a [`SdkMeterProvider`].
///
/// A [`SdkMeterProvider`] is provided so you have the responsibility to
/// do whatever you need to do with it.
///
/// This will not install the recorder as the global recorder for
/// the [`metrics`] crate, use [`Builder::install`]. This will not install a meter
/// provider to [`global`], use [`Builder::install_global`].
pub fn build(self) -> (SdkMeterProvider, Recorder) {
let provider = self.builder.build();
let meter = provider.meter_with_scope(self.scope.build());
(provider, Recorder::with_meter(meter))
}
/// Builds a [`Recorder`] and sets it as the global recorder for the [`metrics`]
/// crate.
///
/// This method will not call [`global::set_meter_provider`] for OpenTelemetry and
/// will be returned as the first element in the return's type tuple.
pub fn install(self) -> Result<(SdkMeterProvider, Recorder), GlobalError> {
let (provider, recorder) = self.build();
metrics::set_global_recorder(recorder.clone())?;
Ok((provider, recorder))
}
/// Builds the [`Recorder`] to record metrics to OpenTelemetry, set the global
/// recorder for the [`metrics`] crate, and calls [`global::set_meter_provider`]
/// to set the constructed [`SdkMeterProvider`].
pub fn install_global(self) -> Result<Recorder, GlobalError> {
let (provider, recorder) = self.install()?;
global::set_meter_provider(provider);
Ok(recorder)
}
}
#[derive(Debug)]
struct MetricMetadata {
unit: Option<Unit>,
description: SharedString,
}
/// A standard recorder that implements [`metrics::Recorder`].
///
/// This instance implements <code>[`Deref`]\<Target = [`Meter`]\></code>, so
/// you can still interact with the SDK's initialized [`Meter`] instance.
#[derive(Debug, Clone)]
pub struct Recorder {
meter: Meter,
metrics_metadata: Arc<Mutex<HashMap<KeyName, MetricMetadata>>>,
// cache metric handlers as to not reregister on each call
cached_counters: Arc<RwLock<HashMap<Key, Counter>>>,
cached_gauges: Arc<RwLock<HashMap<Key, Gauge>>>,
cached_histograms: Arc<RwLock<HashMap<Key, Histogram>>>,
}
impl Recorder {
/// Creates a new [`Builder`] with a given name for instrumentation.
pub fn builder<S: Into<Cow<'static, str>>>(name: S) -> Builder {
Builder {
builder: MeterProviderBuilder::default(),
scope: InstrumentationScope::builder(name.into()),
}
}
/// Creates a [`Recorder`] with an already established [`Meter`].
pub fn with_meter(meter: Meter) -> Self {
Recorder {
meter,
metrics_metadata: Default::default(),
cached_counters: Default::default(),
cached_gauges: Default::default(),
cached_histograms: Default::default(),
}
}
fn get_cached_metric<T: Clone>(lock: &RwLock<HashMap<Key, T>>, key: &Key, metric_type: &str) -> Option<T> {
let cache = match lock.read() {
Ok(g) => g,
Err(e) => {
error!("{} cache read lock poisoned: {}", metric_type, e);
e.into_inner()
}
};
cache.get(key).cloned()
}
fn insert_cached_metric<T: Clone>(lock: &RwLock<HashMap<Key, T>>, key: Key, value: T, metric_type: &str) -> T {
let mut cache = match lock.write() {
Ok(g) => g,
Err(e) => {
error!("{} cache write lock poisoned: {}", metric_type, e);
e.into_inner()
}
};
if let Some(v) = cache.get(&key) {
return v.clone();
}
cache.insert(key, value.clone());
value
}
fn with_metadata_lock<F, R>(&self, f: F) -> R
where
F: FnOnce(&mut HashMap<KeyName, MetricMetadata>) -> R,
{
let mut guard = self.metrics_metadata.lock().unwrap_or_else(|e| {
error!("metrics_metadata lock poisoned: {}", e);
e.into_inner()
});
f(&mut guard)
}
fn describe_metric(&self, key: KeyName, unit: Option<Unit>, description: SharedString) {
self.with_metadata_lock(|metadata| {
metadata.insert(key, MetricMetadata { unit, description });
});
}
fn get_metadata_for_builder(&self, key_name: &str) -> Option<MetricMetadata> {
self.with_metadata_lock(|metadata| metadata.remove(key_name))
}
}
impl Deref for Recorder {
type Target = Meter;
fn deref(&self) -> &Self::Target {
&self.meter
}
}
impl metrics::Recorder for Recorder {
fn describe_counter(&self, key: KeyName, unit: Option<Unit>, description: SharedString) {
self.describe_metric(key, unit, description);
}
fn describe_gauge(&self, key: KeyName, unit: Option<Unit>, description: SharedString) {
self.describe_metric(key, unit, description);
}
fn describe_histogram(&self, key: KeyName, unit: Option<Unit>, description: SharedString) {
self.describe_metric(key, unit, description);
}
fn register_counter(&self, key: &Key, _metadata: &Metadata<'_>) -> Counter {
if let Some(counter) = Self::get_cached_metric(&self.cached_counters, key, "counter") {
return counter;
}
let builder = self.meter.u64_counter(key.name().to_owned());
let metadata = self.get_metadata_for_builder(key.name());
let builder = configure_builder!(builder, metadata);
let counter = builder.build();
let labels = key
.labels()
.map(|label| KeyValue::new(label.key().to_owned(), label.value().to_owned()))
.collect();
let handle = Counter::from_arc(Arc::new(WrappedCounter {
counter,
labels,
value: AtomicU64::new(0),
}));
Self::insert_cached_metric(&self.cached_counters, key.clone(), handle, "counter")
}
fn register_gauge(&self, key: &Key, _metadata: &Metadata<'_>) -> Gauge {
if let Some(gauge) = Self::get_cached_metric(&self.cached_gauges, key, "gauge") {
return gauge;
}
let builder = self.meter.f64_gauge(key.name().to_owned());
let metadata = self.get_metadata_for_builder(key.name());
let builder = configure_builder!(builder, metadata);
let gauge = builder.build();
let labels = key
.labels()
.map(|label| KeyValue::new(label.key().to_owned(), label.value().to_owned()))
.collect();
let handle = Gauge::from_arc(Arc::new(WrappedGauge {
gauge,
labels,
value: AtomicU64::new(0),
}));
Self::insert_cached_metric(&self.cached_gauges, key.clone(), handle, "gauge")
}
fn register_histogram(&self, key: &Key, _metadata: &Metadata<'_>) -> Histogram {
if let Some(histogram) = Self::get_cached_metric(&self.cached_histograms, key, "histogram") {
return histogram;
}
let builder = self.meter.f64_histogram(key.name().to_owned());
let metadata = self.get_metadata_for_builder(key.name());
let builder = configure_builder!(builder, metadata);
let histogram = builder.build();
let labels = key
.labels()
.map(|label| KeyValue::new(label.key().to_owned(), label.value().to_owned()))
.collect();
let handle = Histogram::from_arc(Arc::new(WrappedHistogram { histogram, labels }));
Self::insert_cached_metric(&self.cached_histograms, key.clone(), handle, "histogram")
}
}
struct WrappedCounter {
counter: opentelemetry::metrics::Counter<u64>,
labels: Vec<KeyValue>,
value: AtomicU64,
}
impl CounterFn for WrappedCounter {
fn increment(&self, value: u64) {
self.value.fetch_add(value, Ordering::Relaxed);
self.counter.add(value, &self.labels);
}
fn absolute(&self, value: u64) {
let prev = self.value.swap(value, Ordering::Relaxed);
let diff = value.saturating_sub(prev);
self.counter.add(diff, &self.labels);
}
}
struct WrappedGauge {
gauge: opentelemetry::metrics::Gauge<f64>,
labels: Vec<KeyValue>,
value: AtomicU64,
}
impl GaugeFn for WrappedGauge {
fn increment(&self, value: f64) {
let mut current = self.value.load(Ordering::Relaxed);
let mut new = f64::from_bits(current) + value;
while let Err(val) = self
.value
.compare_exchange(current, new.to_bits(), Ordering::AcqRel, Ordering::Relaxed)
{
current = val;
new = f64::from_bits(current) + value;
}
self.gauge.record(new, &self.labels);
}
fn decrement(&self, value: f64) {
let mut current = self.value.load(Ordering::Relaxed);
let mut new = f64::from_bits(current) - value;
while let Err(val) = self
.value
.compare_exchange(current, new.to_bits(), Ordering::AcqRel, Ordering::Relaxed)
{
current = val;
new = f64::from_bits(current) - value;
}
self.gauge.record(new, &self.labels);
}
fn set(&self, value: f64) {
self.value.store(value.to_bits(), Ordering::Relaxed);
self.gauge.record(value, &self.labels);
}
}
struct WrappedHistogram {
histogram: opentelemetry::metrics::Histogram<f64>,
labels: Vec<KeyValue>,
}
impl HistogramFn for WrappedHistogram {
fn record(&self, value: f64) {
self.histogram.record(value, &self.labels);
}
fn record_many(&self, value: f64, count: usize) {
for _ in 0..count {
self.histogram.record(value, &self.labels);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use metrics::Recorder as _;
use opentelemetry_sdk::metrics::Temporality;
fn test_recorder() -> Recorder {
let exporter = opentelemetry_stdout::MetricExporterBuilder::default()
.with_temporality(Temporality::Cumulative)
.build();
let (_provider, recorder) = Recorder::builder("test")
.with_meter_provider(|b| b.with_periodic_exporter(exporter))
.build();
recorder
}
fn test_metadata() -> Metadata<'static> {
Metadata::new(module_path!(), metrics::Level::INFO, None)
}
#[test]
fn standard_usage() {
let exporter = opentelemetry_stdout::MetricExporterBuilder::default()
.with_temporality(Temporality::Cumulative)
.build();
let (provider, recorder) = Recorder::builder("my-app")
.with_meter_provider(|builder| builder.with_periodic_exporter(exporter))
.build();
global::set_meter_provider(provider.clone());
metrics::set_global_recorder(recorder).unwrap();
let counter = metrics::counter!("my-counter");
counter.increment(1);
provider.force_flush().unwrap();
}
#[test]
fn counter_cached_on_repeated_registration() {
let recorder = test_recorder();
let key = Key::from_name("requests_total");
let meta = test_metadata();
let _first = recorder.register_counter(&key, &meta);
let _second = recorder.register_counter(&key, &meta);
let cache = recorder.cached_counters.read().unwrap();
assert_eq!(cache.len(), 1, "counter should be cached and inserted only once");
}
#[test]
fn gauge_cached_on_repeated_registration() {
let recorder = test_recorder();
let key = Key::from_name("active_connections");
let meta = test_metadata();
let _first = recorder.register_gauge(&key, &meta);
let _second = recorder.register_gauge(&key, &meta);
let cache = recorder.cached_gauges.read().unwrap();
assert_eq!(cache.len(), 1, "gauge should be cached and inserted only once");
}
#[test]
fn histogram_cached_on_repeated_registration() {
let recorder = test_recorder();
let key = Key::from_name("request_duration");
let meta = test_metadata();
let _first = recorder.register_histogram(&key, &meta);
let _second = recorder.register_histogram(&key, &meta);
let cache = recorder.cached_histograms.read().unwrap();
assert_eq!(cache.len(), 1, "histogram should be cached and inserted only once");
}
#[test]
fn concurrent_register_counter_inserts_once() {
let recorder = test_recorder();
let key = Key::from_name("concurrent_counter");
let shared = Arc::new(recorder);
let barrier = Arc::new(std::sync::Barrier::new(10));
let handles: Vec<_> = (0..10)
.map(|_| {
let r = Arc::clone(&shared);
let k = key.clone();
let b = Arc::clone(&barrier);
std::thread::spawn(move || {
b.wait();
let _ = r.register_counter(&k, &test_metadata());
})
})
.collect();
for h in handles {
h.join().unwrap();
}
let cache = shared.cached_counters.read().unwrap();
assert_eq!(cache.len(), 1, "concurrent registrations should produce exactly one cache entry");
}
}
+64
View File
@@ -0,0 +1,64 @@
// 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.
//! OpenTelemetry [`Resource`] construction for RustFS.
//!
//! A `Resource` describes the entity producing telemetry data. The resource
//! built here includes the service name, service version, deployment
//! environment, and the local machine IP address so that data can be
//! correlated across services in a distributed system.
use crate::config::OtelConfig;
use opentelemetry::KeyValue;
use opentelemetry_sdk::Resource;
use opentelemetry_semantic_conventions::{
SCHEMA_URL,
attribute::{DEPLOYMENT_ENVIRONMENT_NAME, NETWORK_LOCAL_ADDRESS, SERVICE_VERSION as OTEL_SERVICE_VERSION},
};
use rustfs_config::{APP_NAME, ENVIRONMENT, SERVICE_VERSION};
use rustfs_utils::get_local_ip_with_default;
use std::borrow::Cow;
/// Build an OpenTelemetry [`Resource`] populated from the provided config.
///
/// The resource carries the following attributes:
/// - `service.name` — from `config.service_name`, defaulting to [`APP_NAME`].
/// - `service.version` — from `config.service_version`, defaulting to
/// [`SERVICE_VERSION`].
/// - `deployment.environment` — from `config.environment`, defaulting to
/// [`ENVIRONMENT`].
/// - `network.local.address` — the primary local IP of the current host,
/// useful for identifying individual nodes in a cluster.
///
/// All attributes are attached to the resource using the semantic conventions
/// schema URL to ensure compatibility with standard OTLP backends.
pub(super) fn build_resource(config: &OtelConfig) -> Resource {
Resource::builder()
.with_service_name(Cow::Borrowed(config.service_name.as_deref().unwrap_or(APP_NAME)).to_string())
.with_schema_url(
[
KeyValue::new(
OTEL_SERVICE_VERSION,
Cow::Borrowed(config.service_version.as_deref().unwrap_or(SERVICE_VERSION)).to_string(),
),
KeyValue::new(
DEPLOYMENT_ENVIRONMENT_NAME,
Cow::Borrowed(config.environment.as_deref().unwrap_or(ENVIRONMENT)).to_string(),
),
KeyValue::new(NETWORK_LOCAL_ADDRESS, get_local_ip_with_default()),
],
SCHEMA_URL,
)
.build()
}