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