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