feat(logging): support fractional request dump retention

Accept positive fractional days and expire request dump segments by elapsed time. Rotate small dump files by age, preserve archive timestamps, and document the retention window.

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Zyy
2026-10-14 13:03:55 +08:00
co-authored by Codex
parent b5bd63b585
commit 3ee454b750
9 changed files with 293 additions and 48 deletions
+16 -11
View File
@@ -72,7 +72,7 @@ fn missing_config_field(error: &config::ConfigError) -> Option<String> {
}
/// Host 唯一拥有的进程配置
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct HostConfig {
#[serde(default)]
pub timezone: gateway_core::time::DeploymentTimeZone,
@@ -145,7 +145,7 @@ pub struct ListenConfig {
}
/// Host 结构化日志配置
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct LoggingConfig {
pub level: String,
pub stdout: bool,
@@ -157,17 +157,17 @@ pub struct LoggingConfig {
/// 请求报文的留存窗口由 request_dump_retention_days 独立控制
#[serde(default)]
pub request_dump: bool,
/// 完整报文的最少保留天数;按 UTC 日期整组清理,默认 1 天
/// 完整报文的最少保留天数;支持小数,每天按 24 小时换算,默认 1 天
#[serde(default = "default_request_dump_retention_days")]
pub request_dump_retention_days: usize,
pub request_dump_retention_days: f64,
}
fn default_file_retention_days() -> usize {
7
}
fn default_request_dump_retention_days() -> usize {
1
fn default_request_dump_retention_days() -> f64 {
1.0
}
impl LoggingConfig {
@@ -190,14 +190,19 @@ impl LoggingConfig {
"host.logging.file.max_file_size_mb",
));
}
if self.request_dump_retention_days == 0 {
return Err(ConfigError::InvalidField(
"host.logging.request_dump_retention_days",
));
}
self.request_dump_retention()?;
resolve_relative_path(source_dir, &mut self.file.directory);
Ok(())
}
pub(crate) fn request_dump_retention(&self) -> Result<Duration, ConfigError> {
Duration::try_from_secs_f64(self.request_dump_retention_days * 86_400.0)
.ok()
.filter(|duration| !duration.is_zero())
.ok_or(ConfigError::InvalidField(
"host.logging.request_dump_retention_days",
))
}
}
/// Host 文件日志配置
@@ -15,7 +15,7 @@ mod sink;
mod writer;
use sink::{FileLogGuard, FileLogSink, LogHealth};
use writer::RotatingLogWriter;
use writer::{LogRetention, RotatingLogWriter};
// 文件前缀统一为 codex-proxy-rs-<kebab-case 类别>;专用 tracing target 使用 snake_case
const APPLICATION_LOG_FILE_PREFIX: &str = "codex-proxy-rs-application";
@@ -45,9 +45,11 @@ pub enum LogError {
AlreadyInitialized,
#[error("logging size limit is too large")]
SizeOverflow,
#[error(transparent)]
InvalidConfig(#[from] crate::config::ConfigError),
}
/// 初始化日志;文件按完整日期留存,不以分片数量淘汰窗口内记录
/// 初始化日志;按各通道的保留窗口清理,不以分片数量淘汰记录
pub fn initialize_logging(
config: &LoggingConfig,
timezone: gateway_core::time::DeploymentTimeZone,
@@ -74,7 +76,7 @@ pub fn initialize_logging(
let (writer, guard) = create_file_writer(
config,
timezone,
config.file.retention_days,
LogRetention::CalendarDays(config.file.retention_days),
APPLICATION_LOG_FILE_PREFIX,
"gateway-log-application-file",
Arc::clone(&health),
@@ -88,7 +90,7 @@ pub fn initialize_logging(
let (writer, guard) = create_file_writer(
config,
timezone,
config.file.retention_days,
LogRetention::CalendarDays(config.file.retention_days),
OAUTH_RECOVERY_LOG_FILE_PREFIX,
"gateway-log-oauth-recovery-file",
Arc::clone(&health),
@@ -102,7 +104,7 @@ pub fn initialize_logging(
let (writer, guard) = create_file_writer(
config,
timezone,
config.request_dump_retention_days,
LogRetention::Elapsed(config.request_dump_retention()?),
REQUEST_DUMP_LOG_FILE_PREFIX,
"gateway-log-request-dump-file",
Arc::clone(&health),
@@ -220,7 +222,7 @@ impl LogGuard {
fn create_file_writer(
config: &LoggingConfig,
timezone: gateway_core::time::DeploymentTimeZone,
retention_days: usize,
retention: LogRetention,
prefix: &'static str,
thread_name: &'static str,
health: Arc<LogHealth>,
@@ -234,7 +236,7 @@ fn create_file_writer(
config.file.directory.clone(),
prefix,
maximum_bytes,
retention_days,
retention,
Arc::clone(&health),
timezone,
)?;
@@ -1,22 +1,30 @@
//! 按大小轮转、压缩已关闭分片、按部署时区自然日期组清理
//! 按大小与时间轮转、压缩已关闭分片,按自然日期或实际时长清理
use std::collections::BTreeMap;
use std::fs::{self, File, OpenOptions};
use std::io::{self, BufWriter, Write};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{Duration, Instant, SystemTime};
use chrono::{NaiveDate, Utc};
use flate2::{Compression, write::GzEncoder};
use super::sink::LogHealth;
#[derive(Clone, Copy)]
pub(super) enum LogRetention {
CalendarDays(usize),
Elapsed(Duration),
}
pub(super) struct RotatingLogWriter {
timezone: gateway_core::time::DeploymentTimeZone,
directory: PathBuf,
prefix: &'static str,
maximum_bytes: u64,
retention_days: usize,
retention: LogRetention,
opened_at: Instant,
date: NaiveDate,
segment: usize,
bytes_written: u64,
@@ -29,19 +37,23 @@ impl RotatingLogWriter {
directory: PathBuf,
prefix: &'static str,
maximum_bytes: u64,
retention_days: usize,
retention: LogRetention,
health: Arc<LogHealth>,
timezone: gateway_core::time::DeploymentTimeZone,
) -> io::Result<Self> {
fs::create_dir_all(&directory)?;
let date = timezone.local(Utc::now()).date_naive();
cleanup_log_files(&directory, prefix, date, retention_days, timezone)?;
cleanup_log_files(&directory, prefix, retention, timezone, None)?;
let latest = managed_log_files(&directory, prefix)?
.into_iter()
.filter(|entry| entry.date == date)
.max_by_key(|entry| (entry.segment, entry.compressed));
let (segment, bytes_written) = match latest {
Some(entry) if !entry.compressed && entry.path.metadata()?.len() < maximum_bytes => {
Some(entry)
if matches!(retention, LogRetention::CalendarDays(_))
&& !entry.compressed
&& entry.path.metadata()?.len() < maximum_bytes =>
{
(entry.segment, entry.path.metadata()?.len())
}
Some(entry) => (next_segment(entry.segment)?, 0),
@@ -54,7 +66,8 @@ impl RotatingLogWriter {
directory,
prefix,
maximum_bytes,
retention_days,
retention,
opened_at: Instant::now(),
date,
segment,
bytes_written,
@@ -78,7 +91,14 @@ impl RotatingLogWriter {
let day_changed = date != self.date;
let size_exceeded = self.bytes_written > 0
&& self.bytes_written.saturating_add(incoming_bytes as u64) > self.maximum_bytes;
if !day_changed && !size_exceeded {
// 小流量转储也需及时关闭分片,避免小数保留期仍要等自然日切换才能生效
let age_exceeded = match self.retention {
LogRetention::CalendarDays(_) => false,
LogRetention::Elapsed(duration) => {
self.opened_at.elapsed() >= duration.min(Duration::from_secs(3600))
}
};
if !day_changed && !size_exceeded && !age_exceeded {
return Ok(());
}
let previous = self
@@ -104,6 +124,7 @@ impl RotatingLogWriter {
self.date = date;
self.segment = segment;
self.bytes_written = self.file.get_ref().metadata()?.len();
self.opened_at = Instant::now();
if let Err(error) = compress_log_file(&previous) {
self.health.maintenance_failed(error.kind());
}
@@ -111,9 +132,9 @@ impl RotatingLogWriter {
if let Err(error) = cleanup_log_files(
&self.directory,
self.prefix,
date,
self.retention_days,
self.retention,
self.timezone,
Some(&path),
) {
self.health.maintenance_failed(error.kind());
}
@@ -203,17 +224,38 @@ fn managed_log_files(directory: &Path, prefix: &str) -> io::Result<Vec<ManagedLo
fn cleanup_log_files(
directory: &Path,
prefix: &str,
today: NaiveDate,
retention_days: usize,
retention: LogRetention,
timezone: gateway_core::time::DeploymentTimeZone,
active_path: Option<&Path>,
) -> io::Result<()> {
let files = managed_log_files(directory, prefix)?;
let retention_days = match retention {
LogRetention::CalendarDays(days) => days,
LogRetention::Elapsed(duration) => {
let now = SystemTime::now();
for entry in files {
if active_path == Some(entry.path.as_path()) {
continue;
}
let modified = entry.path.metadata()?.modified()?;
if now
.duration_since(modified)
.is_ok_and(|age| age >= duration)
{
fs::remove_file(entry.path)?;
}
}
return Ok(());
}
};
let today = timezone.local(Utc::now()).date_naive();
let days =
u64::try_from(retention_days).map_err(|_| io::Error::other("log retention overflow"))?;
let cutoff = today
.checked_sub_days(chrono::Days::new(days))
.unwrap_or(NaiveDate::MIN);
let mut dates = BTreeMap::<NaiveDate, Vec<PathBuf>>::new();
for entry in managed_log_files(directory, prefix)? {
for entry in files {
dates.entry(entry.date).or_default().push(entry.path);
}
for (date, paths) in dates {
+52 -3
View File
@@ -146,7 +146,7 @@ fn logging_rejects_zero_retention_windows() {
for request_dump in [false, true] {
let mut config = valid_config();
if request_dump {
config.logging.request_dump_retention_days = 0;
config.logging.request_dump_retention_days = 0.0;
} else {
config.logging.file.retention_days = 0;
}
@@ -168,7 +168,7 @@ fn logging_defaults_to_time_retention_and_ignores_removed_count_retention() {
"file": { "enabled": true, "directory": "logs", "max_file_size_mb": 20 }
});
let config: LoggingConfig = serde_json::from_value(value.clone()).unwrap();
assert_eq!(config.request_dump_retention_days, 1);
assert_eq!(config.request_dump_retention_days, 1.0);
assert_eq!(config.file.retention_days, 7);
assert!(!config.oauth_recovery);
value["file"]["max_files"] = serde_json::json!(20);
@@ -178,6 +178,55 @@ fn logging_defaults_to_time_retention_and_ignores_removed_count_retention() {
);
}
#[test]
fn request_dump_retention_accepts_integer_and_fractional_yaml_days() {
for (input, expected) in [("1", 1.0), ("0.5", 0.5), ("0.25", 0.25), ("1.5", 1.5)] {
let yaml = format!(
"level: info\nstdout: true\nrequest_dump_retention_days: {input}\nfile:\n enabled: true\n directory: logs\n max_file_size_mb: 20\n"
);
let mut config = valid_config();
config.logging = config::Config::builder()
.add_source(config::File::from_str(&yaml, config::FileFormat::Yaml))
.build()
.unwrap()
.try_deserialize()
.unwrap();
config
.resolve_and_validate(
std::path::Path::new("/srv/gateway"),
std::path::Path::new("/srv/gateway/web/dist"),
)
.unwrap();
assert_eq!(config.logging.request_dump_retention_days, expected);
}
}
#[test]
fn request_dump_retention_rejects_invalid_durations() {
for days in [
0.0,
-0.5,
f64::NAN,
f64::INFINITY,
f64::NEG_INFINITY,
f64::MAX,
1e-20,
] {
let mut config = valid_config();
config.logging.request_dump_retention_days = days;
let error = config
.resolve_and_validate(
std::path::Path::new("/srv/gateway"),
std::path::Path::new("/srv/gateway/web/dist"),
)
.unwrap_err();
assert!(matches!(
error,
gateway_host::ConfigError::InvalidField("host.logging.request_dump_retention_days")
));
}
}
#[test]
fn dedicated_file_channels_can_be_enabled_without_application_sinks() {
for oauth_recovery in [false, true] {
@@ -220,7 +269,7 @@ fn valid_config() -> HostConfig {
},
oauth_recovery: false,
request_dump: false,
request_dump_retention_days: 1,
request_dump_retention_days: 1.0,
},
system_update,
worker_shutdown_timeout_seconds: 30,
@@ -48,7 +48,7 @@ fn logging_requires_at_least_one_sink() {
},
oauth_recovery: false,
request_dump: false,
request_dump_retention_days: 1,
request_dump_retention_days: 1.0,
},
system_update: SystemUpdateConfig::default(),
worker_shutdown_timeout_seconds: 30,
@@ -325,7 +325,7 @@ fn logging_config(directory: PathBuf, request_dump: bool) -> HostConfig {
},
oauth_recovery: true,
request_dump,
request_dump_retention_days: 1,
request_dump_retention_days: 1.0,
},
system_update: SystemUpdateConfig::default(),
worker_shutdown_timeout_seconds: 30,
@@ -1,4 +1,4 @@
//! 验证日志压缩、轮转与重启后按日期保留完整记录
//! 验证日志压缩、轮转,以及重启后的自然日与小数天数保留窗口
use super::*;
use std::time::SystemTime;
@@ -10,12 +10,11 @@ fn retention_preserves_complete_dates_across_compression_rotation_and_restart()
if env::var_os(CHILD_PROCESS_ENV).is_some() {
with_file_logging(
PathBuf::from(env::var_os(LOG_DIRECTORY_ENV).unwrap()),
true,
false,
|| {
let payload = "x".repeat(1024 * 1024);
// 三条记录已跨过 1 MiB 轮转边界;保留日期的 25 个分段由下方单独构造
for sequence in 0..ROTATION_RECORDS {
tracing::info!(target: REQUEST_DUMP_LOG_TARGET, sequence, payload, "retention record");
tracing::info!(target: APPLICATION_LOG_TARGET, sequence, payload, "retention record");
tracing::info!(target: OAUTH_RECOVERY_LOG_TARGET, sequence, payload, "retention record");
}
@@ -32,7 +31,6 @@ fn retention_preserves_complete_dates_across_compression_rotation_and_restart()
for (prefix, days) in [
(APPLICATION_LOG_FILE_PREFIX, 7),
(OAUTH_RECOVERY_LOG_FILE_PREFIX, 7),
(REQUEST_DUMP_LOG_FILE_PREFIX, 1),
] {
let boundary = today - chrono::Days::new(days);
for segment in 1..=25 {
@@ -90,11 +88,7 @@ fn retention_preserves_complete_dates_across_compression_rotation_and_restart()
);
assert!(unrelated.exists());
for prefix in [
APPLICATION_LOG_FILE_PREFIX,
OAUTH_RECOVERY_LOG_FILE_PREFIX,
REQUEST_DUMP_LOG_FILE_PREFIX,
] {
for prefix in [APPLICATION_LOG_FILE_PREFIX, OAUTH_RECOVERY_LOG_FILE_PREFIX] {
let body = read_log_file_set(directory.path(), prefix);
let mut sequences = [0; ROTATION_RECORDS];
let mut boundaries = std::collections::BTreeSet::new();
@@ -141,6 +135,151 @@ fn retention_preserves_complete_dates_across_compression_rotation_and_restart()
}
}
#[test]
fn request_dump_retention_uses_elapsed_time_across_compression_and_restart() {
const RETENTION_ENV: &str = "CPR_LOGGING_TEST_RETENTION_DAYS";
if env::var_os(CHILD_PROCESS_ENV).is_some() {
let mut config =
logging_config(PathBuf::from(env::var_os(LOG_DIRECTORY_ENV).unwrap()), true);
config.logging.request_dump_retention_days =
env::var(RETENTION_ENV).unwrap().parse().unwrap();
with_logging(config, || {
let payload = "x".repeat(1024 * 1024);
for sequence in 0..ROTATION_RECORDS {
tracing::info!(target: REQUEST_DUMP_LOG_TARGET, sequence, payload, "elapsed retention record");
}
});
return;
}
for days in [0.25, 0.5, 1.0, 1.5] {
let directory = tempfile::tempdir().unwrap();
let now = chrono::Utc::now();
let cutoff = now - chrono::Duration::seconds((days * 86_400.0) as i64);
let mut retained = Vec::new();
let mut expired = Vec::new();
// 同一日期内既有过期文件也有有效文件,文件名日期不覆盖实际修改时间
for (segment, modified, keep) in [
(0, cutoff - chrono::Duration::minutes(1), false),
(1, cutoff + chrono::Duration::minutes(1), true),
(2, now + chrono::Duration::hours(1), true),
] {
let path = directory.path().join(format!(
"{REQUEST_DUMP_LOG_FILE_PREFIX}{}.{segment}.log",
now.date_naive()
));
fs::write(&path, format!("seed-{segment}\n")).unwrap();
fs::File::open(&path)
.unwrap()
.set_modified(modified.into())
.unwrap();
if keep {
let modified = path.metadata().unwrap().modified().unwrap();
retained.push((path, modified));
} else {
expired.push(path);
}
}
// 归档的修改时间也参与清理,压缩不能重置保留窗口
let archive = directory.path().join(format!(
"{REQUEST_DUMP_LOG_FILE_PREFIX}{}.3.log.gz",
now.date_naive() - chrono::Days::new(3)
));
let mut encoder = flate2::write::GzEncoder::new(
fs::File::create(&archive).unwrap(),
flate2::Compression::fast(),
);
std::io::Write::write_all(&mut encoder, b"expired archive\n").unwrap();
encoder
.finish()
.unwrap()
.set_modified((cutoff - chrono::Duration::hours(1)).into())
.unwrap();
expired.push(archive);
let unrelated = directory.path().join("unmanaged.log");
fs::write(&unrelated, "unmanaged\n").unwrap();
for run in 1..=2 {
let output = Command::new(env::current_exe().unwrap())
.args(["--exact", "logging::writer::request_dump_retention_uses_elapsed_time_across_compression_and_restart"])
.env(CHILD_PROCESS_ENV, "1")
.env(LOG_DIRECTORY_ENV, directory.path())
.env(LOG_TIMEZONE_ENV, "America/New_York")
.env(RETENTION_ENV, days.to_string())
.env("RUST_LOG", "off")
.output().unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
for (path, modified) in &retained {
let archive = path.with_extension("log.gz");
assert!(archive.exists());
assert_eq!(archive.metadata().unwrap().modified().unwrap(), *modified);
}
assert!(
expired
.iter()
.all(|path| !path.exists() && !path.with_extension("log.gz").exists())
);
assert!(unrelated.exists());
let body = read_log_file_set(directory.path(), REQUEST_DUMP_LOG_FILE_PREFIX);
assert!(body.contains("seed-1\n") && body.contains("seed-2\n"));
assert_eq!(
body.matches("elapsed retention record").count(),
run * ROTATION_RECORDS
);
}
}
}
#[test]
fn request_dump_rotates_small_files_when_the_time_window_elapses() {
if env::var_os(CHILD_PROCESS_ENV).is_some() {
let directory = PathBuf::from(env::var_os(LOG_DIRECTORY_ENV).unwrap());
let mut config = logging_config(directory.clone(), true);
config.logging.request_dump_retention_days = 1.0 / 86_400.0;
with_logging(config, || {
tracing::info!(target: REQUEST_DUMP_LOG_TARGET, "before retention window");
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
loop {
let body = read_log_file_set(&directory, REQUEST_DUMP_LOG_FILE_PREFIX);
if body.contains("before retention window") {
break;
}
assert!(
std::time::Instant::now() < deadline,
"first record was not flushed"
);
std::thread::sleep(std::time::Duration::from_millis(10));
}
std::thread::sleep(std::time::Duration::from_millis(1100));
tracing::info!(target: REQUEST_DUMP_LOG_TARGET, "after retention window");
});
return;
}
let directory = tempfile::tempdir().unwrap();
let output = Command::new(env::current_exe().unwrap())
.args([
"--exact",
"logging::writer::request_dump_rotates_small_files_when_the_time_window_elapses",
])
.env(CHILD_PROCESS_ENV, "1")
.env(LOG_DIRECTORY_ENV, directory.path())
.env("RUST_LOG", "off")
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
let body = read_log_file_set(directory.path(), REQUEST_DUMP_LOG_FILE_PREFIX);
assert!(!body.contains("before retention window"));
assert!(body.contains("after retention window"));
}
fn seed_log(path: &Path, date: chrono::NaiveDate, body: &str) {
fs::write(path, body).unwrap();
let modified = gateway_core::time::DeploymentTimeZone::default()
@@ -56,7 +56,7 @@ pub fn configuration(directory: &Path) -> HostConfig {
},
oauth_recovery: false,
request_dump: false,
request_dump_retention_days: 1,
request_dump_retention_days: 1.0,
},
system_update: Default::default(),
worker_shutdown_timeout_seconds: 1,
+10 -3
View File
@@ -457,19 +457,26 @@ Provider schema 以明文 JSON 保存于 PostgreSQL。Redis 只保存可重建
| 文件集 | 保留配置 | 默认完整窗口 |
| --- | --- | --- |
| 普通日志、OAuth 恢复日志 | `host.logging.file.retention_days` | 前 7 个完整自然日及当天 |
| 全量请求/响应报文 | `host.logging.request_dump_retention_days` | 前 1 个完整自然日及当天 |
| 全量请求/响应报文 | `host.logging.request_dump_retention_days` | 最近 24 小时 |
文件名统一为 `codex-proxy-rs-<类别>.YYYY-MM-DD[.N].log[.gz]`,类别分别为
`application`、`oauth-recovery`、`request-dump`。专用 tracing target 为 `oauth_recovery` 和
`request_dump`;普通日志保留各 Rust 模块的 target,便于按模块过滤。
程序只管理上述规范名称的日志,其他命名的文件由运维手动清理。
日志时间戳采用部署时区并携带数字偏移,文件按该时区的日期轮转和整组保留:例如 9 月 8 日配置 1 天,会保留 9 月 7 日全天及 9 月 8 日的所有分片,
日志时间戳采用部署时区并携带数字偏移。普通日志与 OAuth 恢复日志按该时区的日期轮转和整组保留:例如 9 月 8 日配置 1 天,会保留 9 月 7 日全天及 9 月 8 日的所有分片,
到 9 月 9 日才允许清理 9 月 7 日,避免留下半天日志。夏令时日期允许为 23 或 25 小时。切换时区不重命名历史文件;
按文件名日期与部署时区中的实际修改日期取较近值,旧日期分片近期被写入时整组延后删除。
请求转储保留期支持正小数,按每天 24 小时换算,例如 `0.5` 为 12 小时、`0.25` 为 6 小时。
已关闭分片以最后修改时间判断过期,整片删除,不受时区或夏令时影响。
时间轮转间隔取一小时与保留期中的较小值,在下一条记录写入时触发。
实际保存时间可能长于配置值,取决于分片的最后写入时间和下次清理时机
`max_file_size_mb` 仅决定分片大小(默认 20 MiB),不决定保存时长,单条大记录不会被截断。
关闭的分片压缩为 `.log.gz`;成功压缩、同步并发布归档后才删除原文件,保留原修改时间。
清理发生在启动和轮转时;空闲期间过期文件可能暂时多保留。检索时须同时读取 `.log` 与 `.log.gz`
清理发生在启动和轮转时;空闲期间过期文件可能暂时多保留。请求转储重启时新建分片,避免反复追加延后旧记录的过期点。
检索时须同时读取 `.log` 与 `.log.gz`
报文开关为 `host.logging.request_dump`,默认关闭;开启时原始报文按块写入独立文件,
缓冲拒收或写入失败可能造成缺块,需结合 `file_logging` 健康状态判断完整性。
+1
View File
@@ -24,6 +24,7 @@ host:
oauth_recovery: false
# 将完整请求与响应头、正文写入独立文件;包含凭据,仅排障时开启。
request_dump: false
# 按实际时长保留,支持正小数天数,例如 0.5 为 12 小时
request_dump_retention_days: 1
file:
enabled: true