tauri_plugin_log/
lib.rs

1// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
2// SPDX-License-Identifier: Apache-2.0
3// SPDX-License-Identifier: MIT
4
5//! Logging for Tauri applications.
6
7#![doc(
8    html_logo_url = "https://github.com/tauri-apps/tauri/raw/dev/app-icon.png",
9    html_favicon_url = "https://github.com/tauri-apps/tauri/raw/dev/app-icon.png"
10)]
11
12use fern::{Filter, FormatCallback};
13use log::{logger, RecordBuilder};
14use log::{LevelFilter, Record};
15use serde::Serialize;
16use serde_repr::{Deserialize_repr, Serialize_repr};
17use std::borrow::Cow;
18use std::collections::HashMap;
19use std::{
20    fmt::Arguments,
21    fs::{self, File},
22    iter::FromIterator,
23    path::{Path, PathBuf},
24};
25use tauri::{
26    plugin::{self, TauriPlugin},
27    Manager, Runtime,
28};
29use tauri::{AppHandle, Emitter};
30use time::{macros::format_description, OffsetDateTime};
31
32pub use fern;
33
34pub const WEBVIEW_TARGET: &str = "webview";
35
36#[cfg(target_os = "ios")]
37mod ios {
38    swift_rs::swift!(pub fn tauri_log(
39      level: u8, message: *const std::ffi::c_void
40    ));
41}
42
43const DEFAULT_MAX_FILE_SIZE: u128 = 40000;
44const DEFAULT_ROTATION_STRATEGY: RotationStrategy = RotationStrategy::KeepOne;
45const DEFAULT_TIMEZONE_STRATEGY: TimezoneStrategy = TimezoneStrategy::UseUtc;
46const DEFAULT_LOG_TARGETS: [Target; 2] = [
47    Target::new(TargetKind::Stdout),
48    Target::new(TargetKind::LogDir { file_name: None }),
49];
50const LOG_DATE_FORMAT: &str = "[year]-[month]-[day]_[hour]-[minute]-[second]";
51
52#[derive(Debug, thiserror::Error)]
53pub enum Error {
54    #[error(transparent)]
55    Tauri(#[from] tauri::Error),
56    #[error(transparent)]
57    Io(#[from] std::io::Error),
58    #[error(transparent)]
59    TimeFormat(#[from] time::error::Format),
60    #[error(transparent)]
61    InvalidFormatDescription(#[from] time::error::InvalidFormatDescription),
62    #[error("Internal logger disabled and cannot be acquired or attached")]
63    LoggerNotInitialized,
64}
65
66/// An enum representing the available verbosity levels of the logger.
67///
68/// It is very similar to the [`log::Level`], but serializes to unsigned ints instead of strings.
69#[derive(Debug, Clone, Deserialize_repr, Serialize_repr)]
70#[repr(u16)]
71pub enum LogLevel {
72    /// The "trace" level.
73    ///
74    /// Designates very low priority, often extremely verbose, information.
75    Trace = 1,
76    /// The "debug" level.
77    ///
78    /// Designates lower priority information.
79    Debug,
80    /// The "info" level.
81    ///
82    /// Designates useful information.
83    Info,
84    /// The "warn" level.
85    ///
86    /// Designates hazardous situations.
87    Warn,
88    /// The "error" level.
89    ///
90    /// Designates very serious errors.
91    Error,
92}
93
94impl From<LogLevel> for log::Level {
95    fn from(log_level: LogLevel) -> Self {
96        match log_level {
97            LogLevel::Trace => log::Level::Trace,
98            LogLevel::Debug => log::Level::Debug,
99            LogLevel::Info => log::Level::Info,
100            LogLevel::Warn => log::Level::Warn,
101            LogLevel::Error => log::Level::Error,
102        }
103    }
104}
105
106impl From<log::Level> for LogLevel {
107    fn from(log_level: log::Level) -> Self {
108        match log_level {
109            log::Level::Trace => LogLevel::Trace,
110            log::Level::Debug => LogLevel::Debug,
111            log::Level::Info => LogLevel::Info,
112            log::Level::Warn => LogLevel::Warn,
113            log::Level::Error => LogLevel::Error,
114        }
115    }
116}
117
118pub enum RotationStrategy {
119    /// Will keep all the logs, renaming them to include the date.
120    KeepAll,
121    /// Will only keep the most recent log up to its maximal size.
122    KeepOne,
123    /// Will keep some of the most recent logs, renaming them to include the date.
124    KeepSome(usize),
125}
126
127#[derive(Debug, Clone)]
128pub enum TimezoneStrategy {
129    UseUtc,
130    UseLocal,
131}
132
133impl TimezoneStrategy {
134    pub fn get_now(&self) -> OffsetDateTime {
135        match self {
136            TimezoneStrategy::UseUtc => OffsetDateTime::now_utc(),
137            TimezoneStrategy::UseLocal => {
138                OffsetDateTime::now_local().unwrap_or_else(|_| OffsetDateTime::now_utc())
139            } // Fallback to UTC since Rust cannot determine local timezone
140        }
141    }
142}
143
144#[derive(Debug, Serialize, Clone)]
145struct RecordPayload {
146    message: String,
147    level: LogLevel,
148}
149
150/// An enum representing the available targets of the logger.
151pub enum TargetKind {
152    /// Print logs to stdout.
153    Stdout,
154    /// Print logs to stderr.
155    Stderr,
156    /// Write logs to the given directory.
157    ///
158    /// The plugin will ensure the directory exists before writing logs.
159    Folder {
160        path: PathBuf,
161        file_name: Option<String>,
162    },
163    /// Write logs to the OS specific logs directory.
164    ///
165    /// ### Platform-specific
166    ///
167    /// |Platform   | Value                                                                                     | Example                                                     |
168    /// | --------- | ----------------------------------------------------------------------------------------- | ----------------------------------------------------------- |
169    /// | Linux     | `$XDG_DATA_HOME/{bundleIdentifier}/logs` or `$HOME/.local/share/{bundleIdentifier}/logs`  | `/home/alice/.local/share/com.tauri.dev/logs`               |
170    /// | macOS/iOS | `{homeDir}/Library/Logs/{bundleIdentifier}`                                               | `/Users/Alice/Library/Logs/com.tauri.dev`                   |
171    /// | Windows   | `{FOLDERID_LocalAppData}/{bundleIdentifier}/logs`                                         | `C:\Users\Alice\AppData\Local\com.tauri.dev\logs`           |
172    /// | Android   | `{ConfigDir}/logs`                                                                        | `/data/data/com.tauri.dev/files/logs`                       |
173    LogDir { file_name: Option<String> },
174    /// Forward logs to the webview (via the `log://log` event).
175    ///
176    /// This requires the webview to subscribe to log events, via this plugins `attachConsole` function.
177    Webview,
178    /// Send logs to a [`fern::Dispatch`]
179    ///
180    /// You can use this to construct arbitrary log targets.
181    Dispatch(fern::Dispatch),
182}
183
184/// A log target.
185pub struct Target {
186    kind: TargetKind,
187    filters: Vec<Box<Filter>>,
188}
189
190impl Target {
191    #[inline]
192    pub const fn new(kind: TargetKind) -> Self {
193        Self {
194            kind,
195            filters: Vec::new(),
196        }
197    }
198
199    #[inline]
200    pub fn filter<F>(mut self, filter: F) -> Self
201    where
202        F: Fn(&log::Metadata) -> bool + Send + Sync + 'static,
203    {
204        self.filters.push(Box::new(filter));
205        self
206    }
207}
208
209// Target becomes default and location is added as a parameter
210#[cfg(feature = "tracing")]
211fn emit_trace(
212    level: log::Level,
213    message: &String,
214    location: Option<&str>,
215    file: Option<&str>,
216    line: Option<u32>,
217    kv: &HashMap<&str, &str>,
218) {
219    macro_rules! emit_event {
220        ($level:expr) => {
221            tracing::event!(
222                target: WEBVIEW_TARGET,
223                $level,
224                message = %message,
225                location = location,
226                file,
227                line,
228                ?kv
229            )
230        };
231    }
232    match level {
233        log::Level::Error => emit_event!(tracing::Level::ERROR),
234        log::Level::Warn => emit_event!(tracing::Level::WARN),
235        log::Level::Info => emit_event!(tracing::Level::INFO),
236        log::Level::Debug => emit_event!(tracing::Level::DEBUG),
237        log::Level::Trace => emit_event!(tracing::Level::TRACE),
238    }
239}
240
241#[tauri::command]
242fn log(
243    level: LogLevel,
244    message: String,
245    location: Option<&str>,
246    file: Option<&str>,
247    line: Option<u32>,
248    key_values: Option<HashMap<String, String>>,
249) {
250    let level = log::Level::from(level);
251
252    let target = if let Some(location) = location {
253        format!("{WEBVIEW_TARGET}:{location}")
254    } else {
255        WEBVIEW_TARGET.to_string()
256    };
257
258    let mut builder = RecordBuilder::new();
259    builder.level(level).target(&target).file(file).line(line);
260
261    let key_values = key_values.unwrap_or_default();
262    let mut kv = HashMap::new();
263    for (k, v) in key_values.iter() {
264        kv.insert(k.as_str(), v.as_str());
265    }
266    builder.key_values(&kv);
267    #[cfg(feature = "tracing")]
268    emit_trace(level, &message, location, file, line, &kv);
269
270    logger().log(&builder.args(format_args!("{message}")).build());
271}
272
273pub struct Builder {
274    dispatch: fern::Dispatch,
275    rotation_strategy: RotationStrategy,
276    timezone_strategy: TimezoneStrategy,
277    max_file_size: u128,
278    targets: Vec<Target>,
279    is_skip_logger: bool,
280}
281
282impl Default for Builder {
283    fn default() -> Self {
284        #[cfg(desktop)]
285        let format = format_description!("[[[year]-[month]-[day]][[[hour]:[minute]:[second]]");
286        let dispatch = fern::Dispatch::new().format(move |out, message, record| {
287            out.finish(
288                #[cfg(mobile)]
289                format_args!("[{}] {}", record.target(), message),
290                #[cfg(desktop)]
291                format_args!(
292                    "{}[{}][{}] {}",
293                    DEFAULT_TIMEZONE_STRATEGY.get_now().format(&format).unwrap(),
294                    record.target(),
295                    record.level(),
296                    message
297                ),
298            )
299        });
300        Self {
301            dispatch,
302            rotation_strategy: DEFAULT_ROTATION_STRATEGY,
303            timezone_strategy: DEFAULT_TIMEZONE_STRATEGY,
304            max_file_size: DEFAULT_MAX_FILE_SIZE,
305            targets: DEFAULT_LOG_TARGETS.into(),
306            is_skip_logger: false,
307        }
308    }
309}
310
311impl Builder {
312    pub fn new() -> Self {
313        Default::default()
314    }
315
316    pub fn rotation_strategy(mut self, rotation_strategy: RotationStrategy) -> Self {
317        self.rotation_strategy = rotation_strategy;
318        self
319    }
320
321    pub fn timezone_strategy(mut self, timezone_strategy: TimezoneStrategy) -> Self {
322        self.timezone_strategy = timezone_strategy.clone();
323
324        let format = format_description!("[[[year]-[month]-[day]][[[hour]:[minute]:[second]]");
325        self.dispatch = self.dispatch.format(move |out, message, record| {
326            out.finish(format_args!(
327                "{}[{}][{}] {}",
328                timezone_strategy.get_now().format(&format).unwrap(),
329                record.level(),
330                record.target(),
331                message
332            ))
333        });
334        self
335    }
336
337    pub fn max_file_size(mut self, max_file_size: u128) -> Self {
338        self.max_file_size = max_file_size;
339        self
340    }
341
342    pub fn format<F>(mut self, formatter: F) -> Self
343    where
344        F: Fn(FormatCallback, &Arguments, &Record) + Sync + Send + 'static,
345    {
346        self.dispatch = self.dispatch.format(formatter);
347        self
348    }
349
350    pub fn level(mut self, level_filter: impl Into<LevelFilter>) -> Self {
351        self.dispatch = self.dispatch.level(level_filter.into());
352        self
353    }
354
355    pub fn level_for(mut self, module: impl Into<Cow<'static, str>>, level: LevelFilter) -> Self {
356        self.dispatch = self.dispatch.level_for(module, level);
357        self
358    }
359
360    pub fn filter<F>(mut self, filter: F) -> Self
361    where
362        F: Fn(&log::Metadata) -> bool + Send + Sync + 'static,
363    {
364        self.dispatch = self.dispatch.filter(filter);
365        self
366    }
367
368    /// Removes all targets. Useful to ignore the default targets and reconfigure them.
369    pub fn clear_targets(mut self) -> Self {
370        self.targets.clear();
371        self
372    }
373
374    /// Adds a log target to the logger.
375    ///
376    /// ```rust
377    /// use tauri_plugin_log::{Target, TargetKind};
378    /// tauri_plugin_log::Builder::new()
379    ///     .target(Target::new(TargetKind::Webview));
380    /// ```
381    pub fn target(mut self, target: Target) -> Self {
382        self.targets.push(target);
383        self
384    }
385
386    /// Skip the creation and global registration of a logger
387    ///
388    /// If you wish to use your own global logger, you must call `skip_logger` so that the plugin does not attempt to set a second global logger. In this configuration, no logger will be created and the plugin's `log` command will rely on the result of `log::logger()`. You will be responsible for configuring the logger yourself and any included targets will be ignored. If ever initializing the plugin multiple times, such as if registering the plugin while testing, call this method to avoid panicking when registering multiple loggers. For interacting with `tracing`, you can leverage the `tracing-log` logger to forward logs to `tracing` or enable the `tracing` feature for this plugin to emit events directly to the tracing system. Both scenarios require calling this method.
389    /// ```rust
390    /// static LOGGER: SimpleLogger = SimpleLogger;
391    ///
392    /// log::set_logger(&SimpleLogger)?;
393    /// log::set_max_level(LevelFilter::Info);
394    /// tauri_plugin_log::Builder::new()
395    ///     .skip_logger();
396    /// ```
397    pub fn skip_logger(mut self) -> Self {
398        self.is_skip_logger = true;
399        self
400    }
401
402    /// Replaces the targets of the logger.
403    ///
404    /// ```rust
405    /// use tauri_plugin_log::{Target, TargetKind, WEBVIEW_TARGET};
406    /// tauri_plugin_log::Builder::new()
407    ///     .targets([
408    ///         Target::new(TargetKind::Webview),
409    ///         Target::new(TargetKind::LogDir { file_name: Some("webview".into()) }).filter(|metadata| metadata.target().starts_with(WEBVIEW_TARGET)),
410    ///         Target::new(TargetKind::LogDir { file_name: Some("rust".into()) }).filter(|metadata| !metadata.target().starts_with(WEBVIEW_TARGET)),
411    ///     ]);
412    /// ```
413    pub fn targets(mut self, targets: impl IntoIterator<Item = Target>) -> Self {
414        self.targets = Vec::from_iter(targets);
415        self
416    }
417
418    #[cfg(feature = "colored")]
419    pub fn with_colors(self, colors: fern::colors::ColoredLevelConfig) -> Self {
420        let format = format_description!("[[[year]-[month]-[day]][[[hour]:[minute]:[second]]");
421
422        let timezone_strategy = self.timezone_strategy.clone();
423        self.format(move |out, message, record| {
424            out.finish(format_args!(
425                "{}[{}][{}] {}",
426                timezone_strategy.get_now().format(&format).unwrap(),
427                colors.color(record.level()),
428                record.target(),
429                message
430            ))
431        })
432    }
433
434    fn acquire_logger<R: Runtime>(
435        app_handle: &AppHandle<R>,
436        mut dispatch: fern::Dispatch,
437        rotation_strategy: RotationStrategy,
438        timezone_strategy: TimezoneStrategy,
439        max_file_size: u128,
440        targets: Vec<Target>,
441    ) -> Result<(log::LevelFilter, Box<dyn log::Log>), Error> {
442        let app_name = &app_handle.package_info().name;
443
444        // setup targets
445        for target in targets {
446            let mut target_dispatch = fern::Dispatch::new();
447            for filter in target.filters {
448                target_dispatch = target_dispatch.filter(filter);
449            }
450
451            let logger = match target.kind {
452                #[cfg(target_os = "android")]
453                TargetKind::Stdout | TargetKind::Stderr => fern::Output::call(android_logger::log),
454                #[cfg(target_os = "ios")]
455                TargetKind::Stdout | TargetKind::Stderr => fern::Output::call(move |record| {
456                    let message = format!("{}", record.args());
457                    unsafe {
458                        ios::tauri_log(
459                            match record.level() {
460                                log::Level::Trace | log::Level::Debug => 1,
461                                log::Level::Info => 2,
462                                log::Level::Warn | log::Level::Error => 3,
463                            },
464                            // The string is allocated in rust, so we must
465                            // autorelease it rust to give it to the Swift
466                            // runtime.
467                            objc2::rc::Retained::autorelease_ptr(
468                                objc2_foundation::NSString::from_str(message.as_str()),
469                            ) as _,
470                        );
471                    }
472                }),
473                #[cfg(desktop)]
474                TargetKind::Stdout => std::io::stdout().into(),
475                #[cfg(desktop)]
476                TargetKind::Stderr => std::io::stderr().into(),
477                TargetKind::Folder { path, file_name } => {
478                    if !path.exists() {
479                        fs::create_dir_all(&path)?;
480                    }
481
482                    fern::log_file(get_log_file_path(
483                        &path,
484                        file_name.as_deref().unwrap_or(app_name),
485                        &rotation_strategy,
486                        &timezone_strategy,
487                        max_file_size,
488                    )?)?
489                    .into()
490                }
491                TargetKind::LogDir { file_name } => {
492                    let path = app_handle.path().app_log_dir()?;
493                    if !path.exists() {
494                        fs::create_dir_all(&path)?;
495                    }
496
497                    fern::log_file(get_log_file_path(
498                        &path,
499                        file_name.as_deref().unwrap_or(app_name),
500                        &rotation_strategy,
501                        &timezone_strategy,
502                        max_file_size,
503                    )?)?
504                    .into()
505                }
506                TargetKind::Webview => {
507                    let app_handle = app_handle.clone();
508
509                    fern::Output::call(move |record| {
510                        let payload = RecordPayload {
511                            message: record.args().to_string(),
512                            level: record.level().into(),
513                        };
514                        let app_handle = app_handle.clone();
515                        tauri::async_runtime::spawn(async move {
516                            let _ = app_handle.emit("log://log", payload);
517                        });
518                    })
519                }
520                TargetKind::Dispatch(dispatch) => dispatch.into(),
521            };
522            target_dispatch = target_dispatch.chain(logger);
523
524            dispatch = dispatch.chain(target_dispatch);
525        }
526
527        Ok(dispatch.into_log())
528    }
529
530    fn plugin_builder<R: Runtime>() -> plugin::Builder<R> {
531        plugin::Builder::new("log").invoke_handler(tauri::generate_handler![log])
532    }
533
534    #[allow(clippy::type_complexity)]
535    pub fn split<R: Runtime>(
536        self,
537        app_handle: &AppHandle<R>,
538    ) -> Result<(TauriPlugin<R>, log::LevelFilter, Box<dyn log::Log>), Error> {
539        if self.is_skip_logger {
540            return Err(Error::LoggerNotInitialized);
541        }
542        let plugin = Self::plugin_builder();
543        let (max_level, log) = Self::acquire_logger(
544            app_handle,
545            self.dispatch,
546            self.rotation_strategy,
547            self.timezone_strategy,
548            self.max_file_size,
549            self.targets,
550        )?;
551
552        Ok((plugin.build(), max_level, log))
553    }
554
555    pub fn build<R: Runtime>(self) -> TauriPlugin<R> {
556        Self::plugin_builder()
557            .setup(move |app_handle, _api| {
558                if !self.is_skip_logger {
559                    let (max_level, log) = Self::acquire_logger(
560                        app_handle,
561                        self.dispatch,
562                        self.rotation_strategy,
563                        self.timezone_strategy,
564                        self.max_file_size,
565                        self.targets,
566                    )?;
567                    attach_logger(max_level, log)?;
568                }
569                Ok(())
570            })
571            .build()
572    }
573}
574
575/// Attaches the given logger
576pub fn attach_logger(
577    max_level: log::LevelFilter,
578    log: Box<dyn log::Log>,
579) -> Result<(), log::SetLoggerError> {
580    log::set_boxed_logger(log)?;
581    log::set_max_level(max_level);
582    Ok(())
583}
584
585fn rename_file_to_dated(
586    path: &impl AsRef<Path>,
587    dir: &impl AsRef<Path>,
588    file_name: &str,
589    timezone_strategy: &TimezoneStrategy,
590) -> Result<(), Error> {
591    let to = dir.as_ref().join(format!(
592        "{}_{}.log",
593        file_name,
594        timezone_strategy
595            .get_now()
596            .format(&time::format_description::parse(LOG_DATE_FORMAT).unwrap())
597            .unwrap(),
598    ));
599    if to.is_file() {
600        // designated rotated log file name already exists
601        // highly unlikely but defensively handle anyway by adding .bak to filename
602        let mut to_bak = to.clone();
603        to_bak.set_file_name(format!(
604            "{}.bak",
605            to_bak.file_name().unwrap().to_string_lossy()
606        ));
607        fs::rename(&to, to_bak)?;
608    }
609    fs::rename(path, to)?;
610    Ok(())
611}
612
613fn get_log_file_path(
614    dir: &impl AsRef<Path>,
615    file_name: &str,
616    rotation_strategy: &RotationStrategy,
617    timezone_strategy: &TimezoneStrategy,
618    max_file_size: u128,
619) -> Result<PathBuf, Error> {
620    let path = dir.as_ref().join(format!("{file_name}.log"));
621
622    if path.exists() {
623        let log_size = File::open(&path)?.metadata()?.len() as u128;
624        if log_size > max_file_size {
625            match rotation_strategy {
626                RotationStrategy::KeepAll => {
627                    rename_file_to_dated(&path, dir, file_name, timezone_strategy)?;
628                }
629                RotationStrategy::KeepSome(how_many) => {
630                    let mut files = fs::read_dir(dir)?
631                        .filter_map(|entry| {
632                            let entry = entry.ok()?;
633                            let path = entry.path();
634                            let old_file_name = path.file_name()?.to_string_lossy().into_owned();
635                            if old_file_name.starts_with(file_name) {
636                                let date = old_file_name
637                                    .strip_prefix(file_name)?
638                                    .strip_prefix("_")?
639                                    .strip_suffix(".log")?;
640                                Some((path, date.to_string()))
641                            } else {
642                                None
643                            }
644                        })
645                        .collect::<Vec<_>>();
646                    // Regular sorting, so the oldest files are first. Lexicographical
647                    // sorting is fine due to the date format.
648                    files.sort_by(|a, b| a.1.cmp(&b.1));
649                    // We want to make space for the file we will be soon renaming, AND
650                    // the file we will be creating. Thus we need to keep how_many - 2 files.
651                    if files.len() > (*how_many - 2) {
652                        files.truncate(files.len() + 2 - *how_many);
653                        for (old_log_path, _) in files {
654                            fs::remove_file(old_log_path)?;
655                        }
656                    }
657                    rename_file_to_dated(&path, dir, file_name, timezone_strategy)?;
658                }
659                RotationStrategy::KeepOne => {
660                    fs::remove_file(&path)?;
661                }
662            }
663        }
664    }
665    Ok(path)
666}