diff --git a/apps/app-frontend/src/components/ui/settings/LogExportModal.vue b/apps/app-frontend/src/components/ui/settings/LogExportModal.vue new file mode 100644 index 0000000..fe680eb --- /dev/null +++ b/apps/app-frontend/src/components/ui/settings/LogExportModal.vue @@ -0,0 +1,192 @@ + + + + + + {{ formatMessage(messages.description) }} + + + {{ + formatMessage(messages.rangeLabel) + }} + + + + + {{ + formatMessage(messages.levelLabel) + }} + + + + + {{ + formatMessage(messages.extrasLabel) + }} + + + + + + + + + + {{ formatMessage(messages.cancel) }} + + + {{ formatMessage(messages.confirm) }} + + + + + diff --git a/apps/app-frontend/src/components/ui/settings/LogsSettings.vue b/apps/app-frontend/src/components/ui/settings/LogsSettings.vue new file mode 100644 index 0000000..f7d8c94 --- /dev/null +++ b/apps/app-frontend/src/components/ui/settings/LogsSettings.vue @@ -0,0 +1,66 @@ + + + + + + + + {{ formatMessage(messages.exportTitle) }} + + {{ formatMessage(messages.exportDescription) }} + + + + {{ formatMessage(messages.exportButton) }} + + + {{ formatMessage(messages.openFolder) }} + + + + + + + + + diff --git a/apps/app-frontend/src/components/ui/settings/settings-category-definitions.ts b/apps/app-frontend/src/components/ui/settings/settings-category-definitions.ts index 4adc1c6..3fa7b51 100644 --- a/apps/app-frontend/src/components/ui/settings/settings-category-definitions.ts +++ b/apps/app-frontend/src/components/ui/settings/settings-category-definitions.ts @@ -13,6 +13,7 @@ export type SettingsCategoryId = | 'updates' | 'about' | 'feature-flags' + | 'logs' export type SettingsGroupId = 'launcher' | 'game' | 'data-privacy' | 'support' | 'developer' @@ -116,6 +117,11 @@ export const settingsCategoryDefinitions: SettingsCategoryDefinition[] = [ name: { id: 'app.settings.tabs.about', defaultMessage: 'About' }, group: 'support', }, + { + id: 'logs', + name: { id: 'app.settings.tabs.logs', defaultMessage: 'Logs' }, + group: 'support', + }, { id: 'feature-flags', name: { diff --git a/apps/app-frontend/src/components/ui/settings/settings-registry.ts b/apps/app-frontend/src/components/ui/settings/settings-registry.ts index fb3dc51..4708a1e 100644 --- a/apps/app-frontend/src/components/ui/settings/settings-registry.ts +++ b/apps/app-frontend/src/components/ui/settings/settings-registry.ts @@ -3,6 +3,7 @@ import { BotIcon, CoffeeIcon, CpuIcon, + FileTextIcon, GameIcon, GaugeIcon, InfoIcon, @@ -78,6 +79,10 @@ const categoryContent: Record import('./UpdateSettings.vue')), }, about: { icon: InfoIcon, content: defineAsyncComponent(() => import('./AboutSettings.vue')) }, + logs: { + icon: FileTextIcon, + content: defineAsyncComponent(() => import('./LogsSettings.vue')), + }, 'feature-flags': { icon: ToggleRightIcon, content: defineAsyncComponent(() => import('./FeatureFlagSettings.vue')), diff --git a/apps/app-frontend/src/components/ui/settings/settings-search-index.ts b/apps/app-frontend/src/components/ui/settings/settings-search-index.ts index 96ba1dd..52cebf5 100644 --- a/apps/app-frontend/src/components/ui/settings/settings-search-index.ts +++ b/apps/app-frontend/src/components/ui/settings/settings-search-index.ts @@ -384,6 +384,16 @@ export const settingsSearchEntries: SettingsSearchEntry[] = [ categoryId: 'feature-flags', label: message('settings.feature-flags.title', 'Feature flags'), }, + { + id: 'logs-export', + categoryId: 'logs', + targetId: 'settings-target-logs-export', + label: message('app.settings.logs.export.title', 'Export logs'), + description: message( + 'app.settings.logs.export.description', + 'Package launcher logs with an optional environment summary, instance log, and crash analysis.', + ), + }, ] export function validateSettingsSearchEntries(entries = settingsSearchEntries): string[] { diff --git a/apps/app-frontend/src/helpers/utils.js b/apps/app-frontend/src/helpers/utils.js index 4180108..a3ec220 100644 --- a/apps/app-frontend/src/helpers/utils.js +++ b/apps/app-frontend/src/helpers/utils.js @@ -81,6 +81,38 @@ export async function exportErrorLogs( return await invoke('plugin:utils|export_error_logs', { outputPath, errorMessage }) } +export async function exportLauncherLogs({ + range = 'last2Hours', + level = 'all', + includeSystemInfo = true, + includeInstanceLogs = false, + includeCrashAnalysis = false, + fileNamePrefix = 'Starlight Launcher logs', +} = {}) { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-') + const outputPath = await save({ + defaultPath: `${fileNamePrefix} ${timestamp}.zip`, + filters: [ + { + name: 'ZIP archive', + extensions: ['zip'], + }, + ], + }) + + if (!outputPath) return null + + await invoke('plugin:utils|export_launcher_logs', { + outputPath, + range, + level, + includeSystemInfo, + includeInstanceLogs, + includeCrashAnalysis, + }) + return outputPath +} + export async function createInstanceShortcut(instanceName, instanceId, options = {}) { const outputPath = await save({ defaultPath: `Modrinth - ${instanceName}`, diff --git a/apps/app-frontend/src/locales/en-US/index.json b/apps/app-frontend/src/locales/en-US/index.json index 76e7dec..a0b6f99 100644 --- a/apps/app-frontend/src/locales/en-US/index.json +++ b/apps/app-frontend/src/locales/en-US/index.json @@ -11461,5 +11461,74 @@ }, "app.remote-announcements.preview-action": { "message": "Visit website" + }, + "app.settings.tabs.logs": { + "message": "Logs & diagnostics" + }, + "app.settings.logs.export.title": { + "message": "Export logs" + }, + "app.settings.logs.export.description": { + "message": "Package launcher logs with an optional environment summary, instance log, and crash analysis." + }, + "app.settings.logs.export.button": { + "message": "Export logs…" + }, + "app.settings.logs.open-folder": { + "message": "Open logs folder" + }, + "app.settings.logs.export.modal.title": { + "message": "Export logs" + }, + "app.settings.logs.export.modal.description": { + "message": "Choose how much detail to include. Exported logs have access tokens, Minecraft tokens, and IP addresses replaced with placeholders." + }, + "app.settings.logs.export.modal.range": { + "message": "Time range" + }, + "app.settings.logs.export.modal.range.30-minutes": { + "message": "Last 30 minutes" + }, + "app.settings.logs.export.modal.range.2-hours": { + "message": "Last 2 hours" + }, + "app.settings.logs.export.modal.range.all": { + "message": "All sessions" + }, + "app.settings.logs.export.modal.level": { + "message": "Log level" + }, + "app.settings.logs.export.modal.level.all": { + "message": "All levels" + }, + "app.settings.logs.export.modal.level.debug": { + "message": "Debug and above" + }, + "app.settings.logs.export.modal.level.info": { + "message": "Info and above" + }, + "app.settings.logs.export.modal.extras": { + "message": "Additional information" + }, + "app.settings.logs.export.modal.system-info": { + "message": "System and environment summary" + }, + "app.settings.logs.export.modal.instance-logs": { + "message": "Most recent instance log" + }, + "app.settings.logs.export.modal.crash-analysis": { + "message": "Crash analysis (when a crash is detected)" + }, + "app.settings.logs.export.modal.cancel": { + "message": "Cancel" + }, + "app.settings.logs.export.modal.confirm": { + "message": "Export" + }, + "app.settings.logs.export.notification-title": { + "message": "Logs exported" + }, + "app.settings.logs.export.notification-text": { + "message": "The log archive was saved to {path}" } } diff --git a/apps/app-frontend/src/locales/zh-CN/index.json b/apps/app-frontend/src/locales/zh-CN/index.json index 2065faa..e0dccdc 100644 --- a/apps/app-frontend/src/locales/zh-CN/index.json +++ b/apps/app-frontend/src/locales/zh-CN/index.json @@ -11354,5 +11354,28 @@ "app.anniversary.banner": { "message": "Starlight {ordinal} 周年快乐!" }, "app.action-bar.announcements": { "message": "公告" }, "app.remote-announcements.center-title": { "message": "公告" }, - "app.remote-announcements.empty": { "message": "暂无公告" } + "app.remote-announcements.empty": { "message": "暂无公告" }, + "app.settings.tabs.logs": { "message": "日志与诊断" }, + "app.settings.logs.export.title": { "message": "导出日志" }, + "app.settings.logs.export.description": { "message": "将启动器日志打包,可附带环境摘要、最近的实例日志与崩溃分析。" }, + "app.settings.logs.export.button": { "message": "导出日志…" }, + "app.settings.logs.open-folder": { "message": "打开日志文件夹" }, + "app.settings.logs.export.modal.title": { "message": "导出日志" }, + "app.settings.logs.export.modal.description": { "message": "选择要包含的详细程度。导出的日志会把访问令牌、Minecraft 令牌与 IP 地址替换为占位符。" }, + "app.settings.logs.export.modal.range": { "message": "时间范围" }, + "app.settings.logs.export.modal.range.30-minutes": { "message": "最近 30 分钟" }, + "app.settings.logs.export.modal.range.2-hours": { "message": "最近 2 小时" }, + "app.settings.logs.export.modal.range.all": { "message": "全部会话" }, + "app.settings.logs.export.modal.level": { "message": "日志等级" }, + "app.settings.logs.export.modal.level.all": { "message": "全部等级" }, + "app.settings.logs.export.modal.level.debug": { "message": "DEBUG 及以上" }, + "app.settings.logs.export.modal.level.info": { "message": "INFO 及以上" }, + "app.settings.logs.export.modal.extras": { "message": "附加信息" }, + "app.settings.logs.export.modal.system-info": { "message": "系统与环境摘要" }, + "app.settings.logs.export.modal.instance-logs": { "message": "最近的实例日志" }, + "app.settings.logs.export.modal.crash-analysis": { "message": "崩溃分析(检测到崩溃时)" }, + "app.settings.logs.export.modal.cancel": { "message": "取消" }, + "app.settings.logs.export.modal.confirm": { "message": "导出" }, + "app.settings.logs.export.notification-title": { "message": "日志已导出" }, + "app.settings.logs.export.notification-text": { "message": "日志压缩包已保存到 {path}" } } diff --git a/apps/app/build.rs b/apps/app/build.rs index c37692b..3d33989 100644 --- a/apps/app/build.rs +++ b/apps/app/build.rs @@ -694,6 +694,7 @@ fn main() { "open_path", "show_launcher_logs_folder", "export_error_logs", + "export_launcher_logs", "show_app_db_backups_folder", "progress_bars_list", "get_opening_command", diff --git a/apps/app/src/api/utils.rs b/apps/app/src/api/utils.rs index 9a6c19d..6cc1cd6 100644 --- a/apps/app/src/api/utils.rs +++ b/apps/app/src/api/utils.rs @@ -25,6 +25,7 @@ pub fn init() -> tauri::plugin::TauriPlugin { open_path, show_launcher_logs_folder, export_error_logs, + export_launcher_logs, show_app_db_backups_folder, progress_bars_list, get_opening_command, @@ -164,6 +165,312 @@ pub async fn export_error_logs( Ok(()) } +/// Time window included in a launcher log export. +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum LogExportRange { + Last30Minutes, + Last2Hours, + All, +} + +impl LogExportRange { + fn max_age(self) -> Option { + match self { + Self::Last30Minutes => { + Some(std::time::Duration::from_secs(30 * 60)) + } + Self::Last2Hours => { + Some(std::time::Duration::from_secs(2 * 60 * 60)) + } + Self::All => None, + } + } + + fn label(self) -> &'static str { + match self { + Self::Last30Minutes => "last 30 minutes", + Self::Last2Hours => "last 2 hours", + Self::All => "all sessions", + } + } +} + +/// Lowest level included in a launcher log export. +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum LogExportLevel { + All, + Debug, + Info, +} + +impl LogExportLevel { + fn minimum(self) -> Option<&'static str> { + match self { + Self::All => None, + Self::Debug => Some("debug"), + Self::Info => Some("info"), + } + } + + fn label(self) -> &'static str { + match self { + Self::All => "all levels", + Self::Debug => "debug and above", + Self::Info => "info and above", + } + } +} + +/// Instance log tail included in an export, in bytes. +const LOG_EXPORT_INSTANCE_LOG_TAIL_BYTES: u64 = 512 * 1024; + +/// Export launcher logs (filtered by time range and level) plus optional +/// system information, the most recent instance log, and a crash analysis +/// summary into a ZIP archive. +#[tauri::command] +pub async fn export_launcher_logs( + output_path: PathBuf, + range: LogExportRange, + level: LogExportLevel, + include_system_info: bool, + include_instance_logs: bool, + include_crash_analysis: bool, +) -> Result<()> { + // Censoring needs the app state (credentials, IPs). Without it an export + // would silently ship secrets, so fail rather than degrade. + let state = theseus::State::get().await?; + let archive = tokio::fs::File::create(&output_path).await?; + let mut writer = ZipFileWriter::with_tokio(archive); + + let (exported_logs, skipped_logs) = + write_exported_logs(&mut writer, range, level, &state).await?; + + let mut manifest = format!( + "Starlight Launcher log export\n\ + Exported at: {}\n\ + App version: {}\n\ + Time range: {}\n\ + Level filter: {}\n\ + System information: {}\n\ + Recent instance log: {}\n\ + Crash analysis: {}\n\ + Log files: {exported_logs}\n\n\ + Access tokens, Minecraft tokens, and IP addresses are replaced with placeholders.\n", + chrono::Local::now().to_rfc3339(), + env!("CARGO_PKG_VERSION"), + range.label(), + level.label(), + if include_system_info { + "included" + } else { + "omitted" + }, + if include_instance_logs { + "included" + } else { + "omitted" + }, + if include_crash_analysis { + "included" + } else { + "omitted" + }, + ); + if !skipped_logs.is_empty() { + manifest.push_str("\nSkipped unreadable log files:\n"); + for name in &skipped_logs { + manifest.push_str(&format!("- {name}\n")); + } + } + write_zip_entry(&mut writer, "manifest.txt", manifest.as_bytes()).await?; + + if include_system_info { + let environment = build_environment_report(); + let environment = censor_export_text(environment, &state).await?; + write_zip_entry( + &mut writer, + "system-information.txt", + environment.as_bytes(), + ) + .await?; + } + + let recent_instance = if include_instance_logs || include_crash_analysis { + newest_instance_log(&state).await + } else { + None + }; + + if include_instance_logs + && let Some((instance_id, path)) = recent_instance.as_ref() + { + let tail = read_file_tail(path, LOG_EXPORT_INSTANCE_LOG_TAIL_BYTES)?; + let tail = censor_export_text(tail, &state).await?; + write_zip_entry( + &mut writer, + &format!("minecraft/{instance_id}/latest.log"), + tail.as_bytes(), + ) + .await?; + } + + if include_crash_analysis + && let Some((instance_id, _)) = recent_instance.as_ref() + && let Ok(analysis) = theseus::logs::analyze_crash(instance_id).await + && (analysis.crashed || !analysis.findings.is_empty()) + && let Ok(report) = serde_json::to_vec_pretty(&analysis) + { + // The analysis carries raw log text and Windows event messages, so it + // goes through the same censoring as the exported logs. + let report = censor_export_text( + String::from_utf8_lossy(&report).into_owned(), + &state, + ) + .await?; + write_zip_entry( + &mut writer, + &format!("minecraft/{instance_id}/crash-analysis.json"), + report.as_bytes(), + ) + .await?; + } + + writer.close().await.map_err(zip_error)?; + Ok(()) +} + +/// Writes the matching launcher logs and reports how many were written plus +/// the names of any files that could not be read. +async fn write_exported_logs( + writer: &mut ZipFileWriter, + range: LogExportRange, + level: LogExportLevel, + state: &theseus::State, +) -> Result<(usize, Vec)> { + let Some(directories) = DirectoryInfo::global_handle_if_ready() else { + return Ok((0, Vec::new())); + }; + let Some(logs_dir) = directories.launcher_logs_dir() else { + return Ok((0, Vec::new())); + }; + if !tokio::fs::try_exists(&logs_dir).await? { + return Ok((0, Vec::new())); + } + + let mut entries = tokio::fs::read_dir(&logs_dir).await?; + let mut paths = Vec::new(); + while let Some(entry) = entries.next_entry().await? { + let path = entry.path(); + let is_session_log = + path.file_name().and_then(|name| name.to_str()).is_some_and( + |name| name.starts_with("session_") && name.ends_with(".log"), + ); + if is_session_log && entry.file_type().await?.is_file() { + paths.push(path); + } + } + paths.sort(); + + let mut exported = 0; + let mut skipped = Vec::new(); + for path in paths { + let file_name = path + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| "session.log".to_string()); + + // A single unreadable file must not lose the whole report. + let Ok(contents) = tokio::fs::read(&path).await else { + skipped.push(file_name); + continue; + }; + let filtered = theseus::filter_log_contents( + &contents, + range.max_age(), + level.minimum(), + ); + let text = String::from_utf8_lossy(&filtered).into_owned(); + let censored = censor_export_text(text, state).await?; + write_zip_entry( + writer, + &format!("launcher_logs/{file_name}"), + censored.as_bytes(), + ) + .await?; + exported += 1; + } + + Ok((exported, skipped)) +} + +/// Replaces credentials and IP addresses with placeholders. Censoring failures +/// propagate so an export never ships unredacted content. +async fn censor_export_text( + text: String, + state: &theseus::State, +) -> Result { + Ok(theseus::install::censor_shared_text(text, state).await?) +} + +fn build_environment_report() -> String { + format!( + "Starlight Launcher environment\n\ + App version: {}\n\ + Operating system: {}\n\ + Architecture: {}\n", + env!("CARGO_PKG_VERSION"), + std::env::consts::OS, + std::env::consts::ARCH, + ) +} + +/// The instance whose log was written most recently, if any. +async fn newest_instance_log( + state: &theseus::State, +) -> Option<(String, PathBuf)> { + let instances_dir = state.directories.instances_dir(); + let mut entries = tokio::fs::read_dir(&instances_dir).await.ok()?; + let mut newest: Option<(std::time::SystemTime, String, PathBuf)> = None; + + while let Some(entry) = entries.next_entry().await.ok()? { + if !entry.file_type().await.ok()?.is_dir() { + continue; + } + let log_path = entry.path().join("logs").join("latest.log"); + let Ok(metadata) = tokio::fs::metadata(&log_path).await else { + continue; + }; + let Ok(modified) = metadata.modified() else { + continue; + }; + let is_newer = newest + .as_ref() + .is_none_or(|(newest_modified, ..)| modified > *newest_modified); + if is_newer { + let id = entry.file_name().to_string_lossy().into_owned(); + newest = Some((modified, id, log_path)); + } + } + + newest.map(|(_, id, path)| (id, path)) +} + +fn read_file_tail(path: &Path, max_bytes: u64) -> Result { + use std::io::{Read, Seek, SeekFrom}; + + let mut file = std::fs::File::open(path)?; + let length = file.metadata()?.len(); + if length > max_bytes { + file.seek(SeekFrom::Start(length - max_bytes))?; + } + + let mut contents = Vec::new(); + file.read_to_end(&mut contents)?; + Ok(String::from_utf8_lossy(&contents).into_owned()) +} + async fn write_zip_file( writer: &mut ZipFileWriter, filename: &str, diff --git a/packages/app-lib/src/install/diagnostics.rs b/packages/app-lib/src/install/diagnostics.rs index 2e1aef7..19002a2 100644 --- a/packages/app-lib/src/install/diagnostics.rs +++ b/packages/app-lib/src/install/diagnostics.rs @@ -670,7 +670,7 @@ fn read_file_tail(path: &Path, max_bytes: u64) -> crate::Result { Ok(output) } -async fn censor_support_text( +pub(crate) async fn censor_support_text( mut text: String, state: &State, ) -> crate::Result { diff --git a/packages/app-lib/src/install/mod.rs b/packages/app-lib/src/install/mod.rs index afd1274..8e14364 100644 --- a/packages/app-lib/src/install/mod.rs +++ b/packages/app-lib/src/install/mod.rs @@ -41,3 +41,12 @@ pub use runner::{ retry_job, retry_job_as_new, skip_missing_content_and_resume_job, update_managed_curseforge_modpack, upgrade_unmanaged_instance, }; + +/// Replaces credentials and IP addresses in text the user may share publicly +/// (support reports, exported logs) with placeholders. +pub async fn censor_shared_text( + text: String, + state: &crate::State, +) -> crate::Result { + diagnostics::censor_support_text(text, state).await +} diff --git a/packages/app-lib/src/lib.rs b/packages/app-lib/src/lib.rs index 3b9a628..2b3d70a 100644 --- a/packages/app-lib/src/lib.rs +++ b/packages/app-lib/src/lib.rs @@ -28,6 +28,7 @@ pub use event::{ EventState, LoadingBar, LoadingBarType, emit::emit_loading, emit::init_loading, }; +pub use logger::filter_log_contents; pub use logger::start_logger; pub use state::db::{ backup_current_app_db_for_update, beta_database_exists, diff --git a/packages/app-lib/src/logger.rs b/packages/app-lib/src/logger.rs index d00cca1..0b360c2 100644 --- a/packages/app-lib/src/logger.rs +++ b/packages/app-lib/src/logger.rs @@ -240,6 +240,114 @@ fn launcher_log_is_expired( now.duration_since(created).is_ok_and(|age| age > max_age) } +/// Ordinal for the levels the launcher writes, ordered from most to least +/// verbose. Unknown tokens are ignored so unrecognized lines are never pruned. +fn log_level_ordinal(token: &str) -> Option { + match token { + "TRACE" => Some(0), + "DEBUG" => Some(1), + "INFO" => Some(2), + "WARN" => Some(3), + "ERROR" => Some(4), + _ => None, + } +} + +fn log_level_minimum_ordinal(level: &str) -> Option { + match level { + "trace" => Some(0), + "debug" => Some(1), + "info" => Some(2), + "warn" => Some(3), + "error" => Some(4), + _ => None, + } +} + +/// How a log line relates to the surrounding entries. +enum LogEntryHeader { + /// Timestamp and level parsed: the entry can be filtered. + Classified(chrono::DateTime, u8), + /// Timestamp parsed but the level is unknown: keep it rather than + /// dropping content this logic does not understand. + Unclassified, +} + +/// Reads the timestamp and level of an entry line. Continuation lines of a +/// multi-line message carry no prefix and return `None`, so callers keep them +/// grouped with the entry they belong to. +fn parse_log_entry_header(line: &str) -> Option { + let (timestamp, rest) = line.split_once(char::is_whitespace)?; + let timestamp = chrono::DateTime::parse_from_rfc3339(timestamp).ok()?; + let level = rest.trim_start().split_whitespace().next()?; + Some(match log_level_ordinal(level) { + Some(ordinal) => LogEntryHeader::Classified(timestamp, ordinal), + None => LogEntryHeader::Unclassified, + }) +} + +/// Keeps or drops whole log entries by a predicate over their timestamp and +/// level ordinal, returning `None` when nothing was dropped. +/// +/// Multi-line messages are kept or dropped together with their header, the +/// bytes of kept lines are preserved exactly (no re-encoding), and entries +/// whose level cannot be classified are always kept. +fn filter_log_entries(contents: &[u8], keep: F) -> Option> +where + F: Fn(chrono::DateTime, u8) -> bool, +{ + let mut kept = Vec::with_capacity(contents.len()); + let mut changed = false; + let mut keep_entry = true; + + for line in contents.split_inclusive(|byte| *byte == b'\n') { + match parse_log_entry_header(&String::from_utf8_lossy(line)) { + Some(LogEntryHeader::Classified(timestamp, ordinal)) => { + keep_entry = keep(timestamp, ordinal); + } + Some(LogEntryHeader::Unclassified) => keep_entry = true, + // Continuation line: it follows the decision of its entry. + None => {} + } + + if keep_entry { + kept.extend_from_slice(line); + } else { + changed = true; + } + } + + changed.then_some(kept) +} + +/// Filters launcher log bytes for an export: entries older than `max_age` and +/// entries below `min_level` are removed. Pass `None` for either bound to keep +/// everything on that axis. Multi-line messages stay intact. +pub fn filter_log_contents( + contents: &[u8], + max_age: Option, + min_level: Option<&str>, +) -> Vec { + let now = chrono::Local::now().fixed_offset(); + let minimum = min_level.and_then(log_level_minimum_ordinal); + + filter_log_entries(contents, |timestamp, ordinal| { + if let Some(minimum) = minimum + && ordinal < minimum + { + return false; + } + if let Some(max_age) = max_age { + let age = (now - timestamp).to_std().unwrap_or_default(); + if age > max_age { + return false; + } + } + true + }) + .unwrap_or_else(|| contents.to_vec()) +} + struct LogEventWriter { writer: RotatingLogWriter, buffer: Vec,
{{ formatMessage(messages.description) }}