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 = env::var("IN_DOCKER")
145			.map(|val| val == "true")
146			.unwrap_or(false)
147			.then(|| "logs/".to_string())
148			.unwrap_or_else(|| env::var("LOG_DATA_DIR").unwrap_or_else(|_| "logs/".to_string()));
149
150		let log_dir = format!("{}/", log_dir.trim_end_matches('/'));
151		// set dates
152		let now = Utc::now();
153		let date_str = now.format("%Y-%m-%d").to_string();
154
155		// Get log file path from environment or use default
156		let base_file_path = format!("{}monitor.log", log_dir);
157
158		// verify the log file already exists
159		if Path::new(&base_file_path).exists() {
160			info!(
161				"Base Log file already exists: {}. Proceeding to compute rolled log file path.",
162				base_file_path
163			);
164		}
165
166		// Time-based rolling: compute file name based on the current UTC date.
167		let time_based_path = compute_rolled_file_path(&base_file_path, &date_str, 1);
168
169		// Ensure parent directory exists.
170		if let Some(parent) = Path::new(&time_based_path).parent() {
171			create_dir_all(parent).expect("Failed to create log directory");
172		}
173
174		// Space-based rolling: if an existing log file exceeds 1GB, adopt a new file name.
175		let max_size = parse_log_max_size();
176
177		let final_path =
178			space_based_rolling(&time_based_path, &base_file_path, &date_str, max_size);
179
180		// Create a file appender
181		let file_appender = tracing_appender::rolling::never(
182			Path::new(&final_path).parent().unwrap_or(Path::new(".")),
183			Path::new(&final_path).file_name().unwrap_or_default(),
184		);
185
186		let ansi_stripped_format = StripAnsiFormatter::new(format);
187
188		subscriber
189			.with(
190				fmt::layer()
191					.event_format(ansi_stripped_format)
192					.with_writer(file_appender)
193					.fmt_fields(fmt::format::PrettyFields::new()),
194			)
195			.init();
196	} else {
197		// Initialize the subscriber with stdout
198		subscriber
199			.with(
200				fmt::layer()
201					.event_format(format)
202					.fmt_fields(fmt::format::PrettyFields::new()),
203			)
204			.init();
205	}
206
207	info!("Logging is successfully configured (mode: {})", log_mode);
208	Ok(())
209}
210
211fn parse_log_max_size() -> u64 {
212	env::var("LOG_MAX_SIZE")
213		.map(|s| {
214			s.parse::<u64>()
215				.expect("LOG_MAX_SIZE must be a valid u64 if set")
216		})
217		.unwrap_or(1_073_741_824)
218}
219
220#[cfg(test)]
221mod tests {
222	use super::*;
223	use std::fs::File;
224	use std::io::Write;
225	use tempfile::tempdir;
226
227	#[test]
228	fn test_strip_ansi_escapes() {
229		let input = "\x1b[31mRed text\x1b[0m and \x1b[32mgreen text\x1b[0m";
230		let expected = "Red text and green text";
231		assert_eq!(strip_ansi_escapes(input), expected);
232	}
233
234	#[test]
235	fn test_compute_rolled_file_path() {
236		// Test with .log suffix
237		let result = compute_rolled_file_path("app.log", "2023-01-01", 1);
238		assert_eq!(result, "app-2023-01-01.1.log");
239
240		// Test without .log suffix
241		let result = compute_rolled_file_path("app", "2023-01-01", 2);
242		assert_eq!(result, "app-2023-01-01.2.log");
243
244		// Test with path
245		let result = compute_rolled_file_path("logs/app.log", "2023-01-01", 3);
246		assert_eq!(result, "logs/app-2023-01-01.3.log");
247	}
248
249	#[test]
250	fn test_space_based_rolling() {
251		// Create a temporary directory for our test files
252		let dir = tempdir().expect("Failed to create temp directory");
253		let base_path = dir.path().join("test.log").to_str().unwrap().to_string();
254		let date_str = "2023-01-01";
255
256		// Create an initial file that's larger than our max size
257		let initial_path = compute_rolled_file_path(&base_path, date_str, 1);
258		{
259			let mut file = File::create(&initial_path).expect("Failed to create test file");
260			// Write 100 bytes to the file
261			file.write_all(&[0; 100])
262				.expect("Failed to write to test file");
263		}
264
265		// Test with a max size of 50 bytes (our file is 100 bytes, so it should roll)
266		let result = space_based_rolling(&initial_path, &base_path, date_str, 50);
267		assert_eq!(result, compute_rolled_file_path(&base_path, date_str, 2));
268
269		// Test with a max size of 200 bytes (our file is 100 bytes, so it should not roll)
270		let result = space_based_rolling(&initial_path, &base_path, date_str, 200);
271		assert_eq!(result, initial_path);
272	}
273
274	// This test checks if the LOG_MAX_SIZE environment variable is set to a valid u64 value.
275	#[test]
276	#[should_panic(expected = "LOG_MAX_SIZE must be a valid u64 if set")]
277	fn test_invalid_log_max_size_panics() {
278		std::env::set_var("LOG_MAX_SIZE", "not_a_number");
279		let _ = parse_log_max_size(); // should panic here
280	}
281}