openzeppelin_monitor/utils/logging/
mod.rs

1//! ## Sets up logging by reading configuration from environment variables.
2//!
3//! Environment variables used:
4//! - LOG_MODE: "stdout" (default) or "file"
5//! - LOG_LEVEL: log level ("trace", "debug", "info", "warn", "error"); default is "info"
6//! - LOG_DATA_DIR: directory for log files; default is "logs/"
7//! - LOG_MAX_SIZE: maximum size of log files in bytes; default is 1GB
8//! - IN_DOCKER: "true" if running in Docker; default is "false"
9
10pub mod error;
11
12use chrono::Utc;
13use std::{
14	env,
15	fs::{create_dir_all, metadata},
16	path::Path,
17};
18use tracing::info;
19use tracing_appender;
20use tracing_subscriber::{filter::EnvFilter, fmt, prelude::*};
21
22use tracing::Subscriber;
23use tracing_subscriber::fmt::format::Writer;
24use tracing_subscriber::fmt::{FmtContext, FormatEvent, FormatFields};
25use tracing_subscriber::registry::LookupSpan;
26
27/// Custom formatter that strips ANSI escape codes from log output
28struct StripAnsiFormatter<T> {
29	inner: T,
30}
31
32impl<T> StripAnsiFormatter<T> {
33	fn new(inner: T) -> Self {
34		Self { inner }
35	}
36}
37
38impl<S, N, T> FormatEvent<S, N> for StripAnsiFormatter<T>
39where
40	S: Subscriber + for<'a> LookupSpan<'a>,
41	N: for<'a> FormatFields<'a> + 'static,
42	T: FormatEvent<S, N>,
43{
44	fn format_event(
45		&self,
46		ctx: &FmtContext<'_, S, N>,
47		mut writer: Writer<'_>,
48		event: &tracing::Event<'_>,
49	) -> std::fmt::Result {
50		// Create a buffer to capture the formatted output
51		let mut buf = String::new();
52		let string_writer = Writer::new(&mut buf);
53
54		// Format the event using the inner formatter
55		self.inner.format_event(ctx, string_writer, event)?;
56
57		// Strip ANSI escape codes
58		let stripped = strip_ansi_escapes(&buf);
59
60		// Write the stripped string to the output
61		write!(writer, "{}", stripped)
62	}
63}
64
65/// Strips ANSI escape codes from a string
66fn strip_ansi_escapes(s: &str) -> String {
67	// Simple regex to match ANSI escape sequences
68	// This matches the most common escape sequences like color codes
69	let re = regex::Regex::new(r"\x1b\[[0-9;]*[a-zA-Z]").unwrap();
70	re.replace_all(s, "").to_string()
71}
72
73/// Computes the path of the rolled log file given the base file path and the date string.
74pub fn compute_rolled_file_path(base_file_path: &str, date_str: &str, index: u32) -> String {
75	let trimmed = base_file_path
76		.strip_suffix(".log")
77		.unwrap_or(base_file_path);
78	format!("{}-{}.{}.log", trimmed, date_str, index)
79}
80
81/// Checks if the given log file exceeds the maximum allowed size (in bytes).
82/// If so, it appends a sequence number to generate a new file name.
83/// Returns the final log file path to use.
84/// - `file_path`: the initial time-based log file path.
85/// - `base_file_path`: the original base log file path.
86/// - `date_str`: the current date string.
87/// - `max_size`: maximum file size in bytes (e.g., 1GB).
88pub fn space_based_rolling(
89	file_path: &str,
90	base_file_path: &str,
91	date_str: &str,
92	max_size: u64,
93) -> String {
94	let mut final_path = file_path.to_string();
95	let mut index = 1;
96	while let Ok(metadata) = metadata(&final_path) {
97		if metadata.len() > max_size {
98			final_path = compute_rolled_file_path(base_file_path, date_str, index);
99			index += 1;
100		} else {
101			break;
102		}
103	}
104	final_path
105}
106
107/// Creates a log format with configurable ANSI support
108fn create_log_format(with_ansi: bool) -> fmt::format::Format<fmt::format::Compact> {
109	fmt::format()
110		.with_level(true)
111		.with_target(true)
112		.with_thread_ids(false)
113		.with_thread_names(false)
114		.with_ansi(with_ansi)
115		.compact()
116}
117
118/// Sets up logging by reading configuration from environment variables.
119pub fn setup_logging() -> Result<(), Box<dyn std::error::Error>> {
120	let log_mode = env::var("LOG_MODE").unwrap_or_else(|_| "stdout".to_string());
121	let log_level = env::var("LOG_LEVEL").unwrap_or_else(|_| "info".to_string());
122
123	// Parse the log level
124	let level_filter = match log_level.to_lowercase().as_str() {
125		"trace" => tracing::Level::TRACE,
126		"debug" => tracing::Level::DEBUG,
127		"info" => tracing::Level::INFO,
128		"warn" => tracing::Level::WARN,
129		"error" => tracing::Level::ERROR,
130		_ => tracing::Level::INFO,
131	};
132
133	// Create a format with ANSI disabled for file logging and enabled for stdout
134	let with_ansi = log_mode.to_lowercase() != "file";
135	let format = create_log_format(with_ansi);
136
137	// Create a subscriber with the specified log level
138	let subscriber = tracing_subscriber::registry().with(EnvFilter::new(level_filter.to_string()));
139
140	if log_mode.to_lowercase() == "file" {
141		info!("Logging to file: {}", log_level);
142
143		// Use logs/ directly in container path, otherwise use LOG_DATA_DIR or default to logs/ for host path
144		let log_dir = if env::var("IN_DOCKER")
145			.map(|val| val == "true")
146			.unwrap_or(false)
147		{
148			"logs/".to_string()
149		} else {
150			env::var("LOG_DATA_DIR").unwrap_or_else(|_| "logs/".to_string())
151		};
152
153		let log_dir = format!("{}/", log_dir.trim_end_matches('/'));
154		// set dates
155		let now = Utc::now();
156		let date_str = now.format("%Y-%m-%d").to_string();
157
158		// Get log file path from environment or use default
159		let base_file_path = format!("{}monitor.log", log_dir);
160
161		// verify the log file already exists
162		if Path::new(&base_file_path).exists() {
163			info!(
164				"Base Log file already exists: {}. Proceeding to compute rolled log file path.",
165				base_file_path
166			);
167		}
168
169		// Time-based rolling: compute file name based on the current UTC date.
170		let time_based_path = compute_rolled_file_path(&base_file_path, &date_str, 1);
171
172		// Ensure parent directory exists.
173		if let Some(parent) = Path::new(&time_based_path).parent() {
174			create_dir_all(parent).expect("Failed to create log directory");
175		}
176
177		// Space-based rolling: if an existing log file exceeds 1GB, adopt a new file name.
178		let max_size = parse_log_max_size();
179
180		let final_path =
181			space_based_rolling(&time_based_path, &base_file_path, &date_str, max_size);
182
183		// Create a file appender
184		let file_appender = tracing_appender::rolling::never(
185			Path::new(&final_path).parent().unwrap_or(Path::new(".")),
186			Path::new(&final_path).file_name().unwrap_or_default(),
187		);
188
189		let ansi_stripped_format = StripAnsiFormatter::new(format);
190
191		subscriber
192			.with(
193				fmt::layer()
194					.event_format(ansi_stripped_format)
195					.with_writer(file_appender)
196					.fmt_fields(fmt::format::PrettyFields::new()),
197			)
198			.init();
199	} else {
200		// Initialize the subscriber with stdout
201		subscriber
202			.with(
203				fmt::layer()
204					.event_format(format)
205					.fmt_fields(fmt::format::PrettyFields::new()),
206			)
207			.init();
208	}
209
210	info!("Logging is successfully configured (mode: {})", log_mode);
211	Ok(())
212}
213
214fn parse_log_max_size() -> u64 {
215	env::var("LOG_MAX_SIZE")
216		.map(|s| {
217			s.parse::<u64>()
218				.expect("LOG_MAX_SIZE must be a valid u64 if set")
219		})
220		.unwrap_or(1_073_741_824)
221}
222
223#[cfg(test)]
224mod tests {
225	use super::*;
226	use std::fs::File;
227	use std::io::Write;
228	use tempfile::tempdir;
229
230	#[test]
231	fn test_strip_ansi_escapes() {
232		let input = "\x1b[31mRed text\x1b[0m and \x1b[32mgreen text\x1b[0m";
233		let expected = "Red text and green text";
234		assert_eq!(strip_ansi_escapes(input), expected);
235	}
236
237	#[test]
238	fn test_compute_rolled_file_path() {
239		// Test with .log suffix
240		let result = compute_rolled_file_path("app.log", "2023-01-01", 1);
241		assert_eq!(result, "app-2023-01-01.1.log");
242
243		// Test without .log suffix
244		let result = compute_rolled_file_path("app", "2023-01-01", 2);
245		assert_eq!(result, "app-2023-01-01.2.log");
246
247		// Test with path
248		let result = compute_rolled_file_path("logs/app.log", "2023-01-01", 3);
249		assert_eq!(result, "logs/app-2023-01-01.3.log");
250	}
251
252	#[test]
253	fn test_space_based_rolling() {
254		// Create a temporary directory for our test files
255		let dir = tempdir().expect("Failed to create temp directory");
256		let base_path = dir.path().join("test.log").to_str().unwrap().to_string();
257		let date_str = "2023-01-01";
258
259		// Create an initial file that's larger than our max size
260		let initial_path = compute_rolled_file_path(&base_path, date_str, 1);
261		{
262			let mut file = File::create(&initial_path).expect("Failed to create test file");
263			// Write 100 bytes to the file
264			file.write_all(&[0; 100])
265				.expect("Failed to write to test file");
266		}
267
268		// Test with a max size of 50 bytes (our file is 100 bytes, so it should roll)
269		let result = space_based_rolling(&initial_path, &base_path, date_str, 50);
270		assert_eq!(result, compute_rolled_file_path(&base_path, date_str, 2));
271
272		// Test with a max size of 200 bytes (our file is 100 bytes, so it should not roll)
273		let result = space_based_rolling(&initial_path, &base_path, date_str, 200);
274		assert_eq!(result, initial_path);
275	}
276
277	// This test checks if the LOG_MAX_SIZE environment variable is set to a valid u64 value.
278	#[test]
279	#[should_panic(expected = "LOG_MAX_SIZE must be a valid u64 if set")]
280	fn test_invalid_log_max_size_panics() {
281		std::env::set_var("LOG_MAX_SIZE", "not_a_number");
282		let _ = parse_log_max_size(); // should panic here
283	}
284}