Passing logging instance across different modules

Hello everyone,

I am currently developing a rust application, and I need some advice how I can achieve the following, while keeping the code organized. The application uses egui for its user interface, the module that takes care of setting up all the UI widgets is called AppUI.
When AppUI is initialized, I am creating a new EventLogger instance, as well as a new ImageProcessingPipeline instance.

ui.rs

pub struct AppUI {
    logger: EventLogger,
    image_pipeline: ImageProcessingPipeline,
    // More fields ...
}

impl AppUI {
    pub fn new(ctx: egui::Context) -> Self {
        let logger = EventLogger::new(ctx);
        let image_pipline = ImageProcessingPipeline::new(&logger);
        Self {
            // More fields ...
            logger,
            image_pipeline: image_pipline,
        }
    }
}

The logger is supposed to be accessible to various other modules, such as the ImageProcessingPipeline, which can add new logs to the logger.

image_pipeline.rs

use crate::logging::EventLogger;

pub struct ImageProcessingPipeline<'a> {
    logger: &'a EventLogger,
}

impl<'a> ImageProcessingPipeline<'a> {
    pub fn new(logger: &EventLogger) -> Self {
        Self { logger }
    }

    pub fn process(&self) {
        // I want to be able to log here like this, as well as in other modules ...
        self.logger
            .log(crate::logging::EventType::INFO, "Processing ...");
    }
}

logging.rs

use chrono::Local;
use egui::{Color32, RichText};

pub struct EventLogger {
    log_messages: Vec<LogEntry>,
    ctx: egui::Context,
}

impl EventLogger {
    pub fn new(ctx: egui::Context) -> Self {
        Self {
            ctx,
            log_messages: vec![],
        }
    }
    pub fn log(&mut self, event_type: EventType, event_message: &str) {
        self.log_messages
            .push(LogEntry::new(event_type, event_message));
        self.ctx.request_repaint();
    }

    pub fn get_logs(&self) -> &Vec<LogEntry> {
        &self.log_messages
    }
}

pub enum EventType {
    FAIL,
    WARN,
    PASS,
    INFO,
}

pub struct LogEntry {
    log_type: EventType,
    log_time: String,
    log_message: String,
}

impl LogEntry {
    fn new(log_type: EventType, log_message: &str) -> Self {
        Self {
            log_type,
            log_time: Local::now().format("%I:%M:%S %p").to_string(),
            log_message: log_message.to_owned(),
        }
    }

    pub fn get_log_type(&self) -> RichText {
        match self.log_type {
            EventType::FAIL => RichText::new("[FAIL]").color(Color32::RED),
            EventType::WARN => RichText::new("[WARN]").color(Color32::YELLOW),
            EventType::PASS => RichText::new("[PASS]").color(Color32::GREEN),
            EventType::INFO => RichText::new("[INFO]").color(Color32::BLUE),
        }
        .into()
    }
    pub fn get_log_time(&self) -> &String {
        &self.log_time
    }
    pub fn get_log_message(&self) -> &String {
        &self.log_message
    }
}

The idea here is that the pipeline runs multiple, long-running tasks, and I want to be able to populate the logger from all these tasks.
However, I am not sure what is the best way to make the logger available to the pipeline, and also, the indivudal pipeline tasks.
I don't think, cloning the logger would work, because then I have a different instance I suppose? I think, in that case, the logger instance in the ui thread would not contain the same data as a clone?
However, when I want to pass the logger as reference to the ImageProcessingPipeline module, I am running into lifetime issues.

Can someone guide me a bit, how I should approach this ideally?
Maybe there is a better concept all together for this?

correct, if you clone the logger, each instance would have its own log_messages: Vec<LogEntry> data.

you can allocate the shared logger on the heap, using Rc<EventLogger> (or it's thread safe counterpart Arc<EventLogger>), and cloning a Rc (or Arc) just clones the pointer, not the shared data.

but Rc only solves the problem of sharing, rust doesn't allow shared mutable references. in your case however, you must mutate the shared data (to append log entries to it), so you'll have to also use interior mutability wrappers (RefCell for single thread, Mutex or RwLock for multiple threads), together with the Rc (or Arc)

in this particular example, because egui::Context is a handle type (it's actually an Arc under the hood), I would suggest you make similar wrapper types for your logger type, something like:

pub struct LoggerInner {
    log_messages: Vec<LogEntry>,
}

pub struct EventLogger {
    inner: Arc<Mutex<LoggerInner>>,
    ctx: egui::Context,
}

impl LoogerInner {
    // implement as before
}

impl EventLogger {
    // note `&self` instead of `&mut self`, 
    // because it needs to be shared, we use interior mutability
    pub fn log(&self, ...) {
        let mut inner = self.inner.lock().unwrap();
        inner.log(...);
        self.ctx.request_repaint();
    }
    // this API is not directly implementable. workarounds might include:
    //   for single thread cases, return a `Ref<[LogEntries]>` guard, see `Ref::map()`
    //   for mult-threaded cases, return a `LockGuard<LoggerInner>`, this leaks the inner type
    //   return a owned copy of `Vec<LogEntries>`; this is inefficient for due to heap allocation and deallocation
    //   change to a "scoped" API, i.e. accept a callback function
    pub fn get_logs(&self) -> &[LogEntries] {
        todo!()
    }
}
1 Like