Skip to main content

rpfm_telemetry/
logger.rs

1//---------------------------------------------------------------------------//
2// Copyright (c) 2017-2026 Ismael Gutiérrez González. All rights reserved.
3//
4// This file is part of the Rusted PackFile Manager (RPFM) project,
5// which can be found here: https://github.com/Frodo45127/rpfm.
6//
7// This file is licensed under the MIT license, which can be found here:
8// https://github.com/Frodo45127/rpfm/blob/master/LICENSE.
9//---------------------------------------------------------------------------//
10
11//! Crash reporting and structured logging with Sentry integration.
12//!
13//! This module provides comprehensive error tracking and logging capabilities:
14//! - Local crash report generation with backtraces
15//! - Remote error reporting via Sentry
16//! - Structured runtime logging (info, warning, error levels)
17//! - Automatic panic handling and session tracking
18//!
19//! # Overview
20//!
21//! The logging system is heavily inspired by the `human-panic` crate but provides more
22//! configurability and integration with Sentry for production error tracking.
23//!
24//! # Features
25//!
26//! ## Local Crash Reports
27//!
28//! When a panic occurs, a detailed crash report is saved locally as a TOML file containing:
29//! - Program name and version
30//! - Build type (debug/release)
31//! - Operating system information
32//! - Panic message and location
33//! - Full backtrace
34//!
35//! ## Sentry Integration
36//!
37//! In release builds, crashes and events are automatically uploaded to Sentry for:
38//! - Centralized error tracking
39//! - Session health monitoring
40//! - Breadcrumb trails
41//! - Custom event uploads with attachments
42//!
43//! ## Runtime Logging
44//!
45//! Standard logging macros are available throughout the application:
46//! - [`info!`]: Informational messages (verbose mode only)
47//! - [`warn!`]: Warning messages
48//! - [`error!`]: Error messages
49//!
50//! # Initialization
51//!
52//! The logger **must** be initialized at program startup by calling [`Logger::init()`].
53//! Without initialization, none of the logging features will work.
54//!
55//! # Example
56//!
57//! ```no_run
58//! use rpfm_telemetry::{Logger, info, warn};
59//! use std::path::Path;
60//!
61//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
62//! // Initialize the logger
63//! let _sentry_guard = Logger::init(
64//!     Path::new("logs/crash_reports"),
65//!     true,  // verbose mode
66//!     true,  // set global logger
67//!     Some("rpfm@5.0.0".into())
68//! )?;
69//!
70//! // Use logging throughout the application
71//! info!("Application started");
72//! warn!("Configuration file not found, using defaults");
73//!
74//! // The guard ensures Sentry is properly shut down on drop
75//! # Ok(())
76//! # }
77//! ```
78//!
79//! # Note
80//!
81//! The Sentry integration is only active in release builds. Debug builds will still
82//! generate local crash reports but won't upload to Sentry.
83
84use backtrace::Backtrace;
85use ron::ser::PrettyConfig;
86pub use sentry::{ClientInitGuard, ClientOptions, end_session, end_session_with_status, Envelope, integrations::{log::SentryLogger, tracing::SentryLayer}, protocol::*, release_name, self, SessionMode};
87use serde_derive::Serialize;
88use simplelog::{ColorChoice, CombinedLogger, LevelFilter, SharedLogger, TermLogger, TerminalMode};
89
90use std::borrow::Cow;
91use std::fs::{DirBuilder, File};
92use std::io::{BufWriter, Write};
93use std::{panic, panic::PanicHookInfo};
94use std::path::Path;
95use std::sync::atomic::{AtomicBool, Ordering};
96use std::sync::{Arc, LazyLock, RwLock};
97use std::time::{SystemTime, UNIX_EPOCH};
98
99use crate::{error, info, warn};
100
101/// Current version of the crate from Cargo.toml.
102const VERSION: &str = env!("CARGO_PKG_VERSION");
103
104/// Patch version of the current crate, parsed from `CARGO_PKG_VERSION_PATCH`.
105///
106/// rpfm uses the patch number as the beta marker: a value `>=` [`BETA_PATCH_THRESHOLD`]
107/// identifies a beta build (e.g. `4.7.106` is a beta, `4.7.0` is stable).
108const VERSION_PATCH: u64 = match u64::from_str_radix(env!("CARGO_PKG_VERSION_PATCH"), 10) {
109    Ok(v) => v,
110    Err(_) => panic!("CARGO_PKG_VERSION_PATCH is not a valid u64"),
111};
112
113/// Patch number at or above which a build is considered a beta release.
114const BETA_PATCH_THRESHOLD: u64 = 99;
115
116/// Sentry environment for stable releases.
117const SENTRY_ENV_PRODUCTION: &str = "production";
118
119/// Sentry environment for beta builds (patch `>=` [`BETA_PATCH_THRESHOLD`]).
120const SENTRY_ENV_BETA: &str = "beta";
121
122/// Sentry DSN (Data Source Name) for error reporting.
123///
124/// This must be set before calling [`Logger::init()`] for Sentry integration to work.
125/// The DSN is provided by Sentry when creating a project.
126pub static SENTRY_DSN: LazyLock<Arc<RwLock<String>>> = LazyLock::new(|| Arc::new(RwLock::new(String::new())));
127
128/// Whether automatic Sentry crash reports (panics, auto-captured errors,
129/// session tracking) are allowed to be uploaded. On by default so panics
130/// during early startup (before settings are loaded) are still captured;
131/// callers may opt out via [`set_crash_reports_enabled`] after reading the
132/// `enable_crash_reports` setting.
133///
134/// This is checked from the `before_send` hook installed in [`Logger::init`],
135/// so toggling it at runtime takes effect immediately for all future events.
136static CRASH_REPORTS_ENABLED: AtomicBool = AtomicBool::new(true);
137
138//-------------------------------------------------------------------------------//
139//                              Enums & Structs
140//-------------------------------------------------------------------------------//
141
142/// Error type for the logging crate.
143#[derive(Debug, thiserror::Error)]
144pub enum LogError {
145
146    /// Wrapper for [`std::io::Error`].
147    #[error(transparent)]
148    IoError(#[from] std::io::Error),
149
150    /// Wrapper for [`toml::ser::Error`].
151    #[error(transparent)]
152    TomlSerError(#[from] toml::ser::Error),
153
154    /// Wrapper for [`log::SetLoggerError`].
155    #[error(transparent)]
156    LogError(#[from] log::SetLoggerError),
157
158    /// Wrapper for [`ron::Error`].
159    #[error(transparent)]
160    RonError(#[from] ron::Error),
161
162    /// Wrapper for [`std::time::SystemTimeError`].
163    #[error(transparent)]
164    SystemTimeError(#[from] std::time::SystemTimeError),
165}
166
167/// Result type for the logging crate.
168pub type Result<T> = std::result::Result<T, LogError>;
169
170/// Crash report data structure.
171///
172/// Contains all information needed to generate a detailed crash report that can be
173/// saved locally as a TOML file or uploaded to Sentry. Created automatically when
174/// a panic occurs.
175#[derive(Debug, Serialize)]
176pub struct Logger {
177
178    /// Name of the program that crashed.
179    ///
180    /// Taken from the `CARGO_PKG_NAME` environment variable.
181    name: String,
182
183    /// Version of the program/library.
184    ///
185    /// Taken from the `CARGO_PKG_VERSION` environment variable.
186    crate_version: String,
187
188    /// Build configuration (Debug or Release).
189    ///
190    /// Determined at compile time based on debug assertions.
191    build_type: String,
192
193    /// Operating system information.
194    ///
195    /// Includes OS type and version (e.g., "Windows 11", "Ubuntu 22.04").
196    operating_system: String,
197
198    /// Panic explanation.
199    ///
200    /// Contains the panic message and location (file and line number).
201    explanation: String,
202
203    /// Full backtrace from the panic.
204    ///
205    /// Formatted stack trace showing the call chain leading to the panic.
206    backtrace: String,
207}
208
209//-------------------------------------------------------------------------------//
210//                              Implementations
211//-------------------------------------------------------------------------------//
212
213/// Implementation of `Logger`.
214impl Logger {
215
216    /// Initializes the logging system with crash reporting and Sentry integration.
217    ///
218    /// This function sets up three logging mechanisms:
219    /// 1. **Local crash reports**: Panics are saved as TOML files to disk
220    /// 2. **Sentry crash reporting**: Panics are uploaded to Sentry (release builds only)
221    /// 3. **Runtime logging**: Structured logging via terminal and Sentry breadcrumbs
222    ///
223    /// # Arguments
224    ///
225    /// * `logging_path` - Directory where crash reports will be saved
226    /// * `verbose` - If `true`, log `Info` level messages; if `false`, only `Warn` and above
227    /// * `set_logger` - If `true`, initialize the global logger (disable for testing)
228    /// * `release` - Optional release identifier for Sentry (e.g., `"rpfm@5.0.0"`)
229    ///
230    /// # Returns
231    ///
232    /// Returns a [`ClientInitGuard`] that must be kept alive for the duration of the program.
233    /// Dropping the guard will shut down Sentry and flush pending events.
234    ///
235    /// # Panics
236    ///
237    /// After initialization, any panic in any thread will:
238    /// 1. Generate a local crash report in `logging_path`
239    /// 2. Upload to Sentry (if in release mode and enabled)
240    /// 3. Mark the Sentry session as crashed
241    ///
242    /// # Example
243    ///
244    /// ```no_run
245    /// # use rpfm_telemetry::Logger;
246    /// # use std::path::Path;
247    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
248    /// let _guard = Logger::init(
249    ///     Path::new("crash_reports"),
250    ///     true,  // verbose
251    ///     true,  // set global logger
252    ///     Some("myapp@1.0.0".into())
253    /// )?;
254    ///
255    /// // Logger is now active
256    /// // Keep _guard alive until program exit
257    /// # Ok(())
258    /// # }
259    /// ```
260    pub fn init(logging_path: &Path, verbose: bool, set_logger: bool, release: Option<Cow<'static, str>>) -> Result<ClientInitGuard> {
261
262        // Make sure the provided folder exists.
263        if let Some(parent_folder) = logging_path.parent() {
264            DirBuilder::new().recursive(true).create(parent_folder)?;
265        }
266
267        let log_level = if verbose {
268            LevelFilter::Info
269        } else {
270            LevelFilter::Warn
271        };
272
273        if set_logger {
274
275            // Initialize the combined logger, with a term logger (for runtime logging) and a write logger (for storing on a log file).
276            //
277            // So, fun fact: this thing has a tendency to crash on boot for no reason. So instead of leaving it crashing, we'll make it optional.
278            let loggers: Vec<Box<dyn SharedLogger + 'static>> = vec![TermLogger::new(log_level, simplelog::Config::default(), TerminalMode::Mixed, ColorChoice::Auto)];
279            let combined_logger = CombinedLogger::new(loggers);
280
281            // Initialize Sentry's logger, so anything logged goes to the breadcrumbs too.
282            let logger = SentryLogger::with_dest(combined_logger);
283            log::set_max_level(log_level);
284            let _ = log::set_boxed_logger(Box::new(logger));
285        }
286
287        // Initialize Sentry's guard, for remote reporting. Only for release mode.
288        let dsn = SENTRY_DSN.read().unwrap().to_string();
289
290        // Gate every Sentry event on the crash-reports toggle.
291        let before_send = Arc::new(|event: Event<'static>| -> Option<Event<'static>> {
292            if CRASH_REPORTS_ENABLED.load(Ordering::Relaxed) { Some(event) } else { None }
293        });
294
295        // Separate beta builds from production builds in Sentry.
296        let environment = if is_beta() {
297            Cow::Borrowed(SENTRY_ENV_BETA)
298        } else {
299            Cow::Borrowed(SENTRY_ENV_PRODUCTION)
300        };
301
302        let client_options = ClientOptions {
303            release: release.clone(),
304            environment: Some(environment),
305            sample_rate: 0.3,
306            traces_sample_rate: 0.3,
307            auto_session_tracking: true,
308            session_mode: SessionMode::Application,
309            before_send: Some(before_send),
310            ..Default::default()
311        };
312
313        let sentry_guard = sentry::init((dsn, client_options));
314
315        // Setup the panic hooks to catch panics on all threads, not only the main one.
316        let sentry_enabled = sentry_guard.is_enabled();
317        let orig_hook = panic::take_hook();
318        let logging_path = logging_path.to_owned();
319        panic::set_hook(Box::new(move |info: &PanicHookInfo| {
320            warn!("Panic detected. Generating backtraces and crash logs...");
321
322            // Get the data printed into the logs, because I'm tired of this getting "missed" when is a cross-thread crash.
323            let data = Self::new(info, VERSION);
324            if data.save(&logging_path).is_err() {
325                error!("Failed to generate crash log.");
326            }
327
328            orig_hook(info);
329
330            // Stop tracking session health before existing.
331            if sentry_enabled {
332                end_session_with_status(SessionStatus::Crashed)
333            }
334        }));
335
336        // Return Sentry's guard, so we can keep it alive until everything explodes, or the user closes the program.
337        info!("Logger initialized.");
338        Ok(sentry_guard)
339    }
340
341    /// Creates a crash report from panic information.
342    ///
343    /// This function extracts all relevant information from a panic and constructs
344    /// a structured crash report. The report is created in memory and must be
345    /// explicitly saved with [`Logger::save()`].
346    ///
347    /// # Arguments
348    ///
349    /// * `panic_info` - Panic hook information provided by the panic handler
350    /// * `version` - Version string of the program
351    ///
352    /// # Returns
353    ///
354    /// Returns a populated [`Logger`] instance containing the crash report data.
355    ///
356    /// # Note
357    ///
358    /// This is typically called automatically by the panic hook installed by [`Logger::init()`].
359    pub fn new(panic_info: &PanicHookInfo, version: &str) -> Self {
360
361        let info = os_info::get();
362        let operating_system = format!("OS: {}\nVersion: {}", info.os_type(), info.version());
363
364        let mut explanation = String::new();
365        if let Some(payload) = panic_info.payload().downcast_ref::<&str>() {
366            explanation.push_str(&format!("Cause: {}\n", &payload));
367        }
368
369        match panic_info.location() {
370            Some(location) => explanation.push_str(&format!("Panic occurred in file '{}' at line {}\n", location.file(), location.line())),
371            None => explanation.push_str("Panic location unknown.\n"),
372        }
373
374        Self {
375            name: env!("CARGO_PKG_NAME").to_owned(),
376            crate_version: version.to_owned(),
377            build_type: if cfg!(debug_assertions) { "Debug" } else { "Release" }.to_owned(),
378            operating_system,
379            explanation,
380            backtrace: format!("{:#?}", Backtrace::new()),
381        }
382    }
383
384    /// Saves the crash report to a TOML file.
385    ///
386    /// The crash report is saved with a timestamped filename in the format
387    /// `error-report-{timestamp}.toml`.
388    ///
389    /// # Arguments
390    ///
391    /// * `path` - Directory where the crash report file should be saved
392    ///
393    /// # Returns
394    ///
395    /// Returns [`Ok`] if the report was saved successfully, or an error if file I/O fails.
396    pub fn save(&self, path: &Path) -> Result<()> {
397        let current_time = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
398        let file_path = path.join(format!("error-report-{}.toml", current_time));
399        let mut file = BufWriter::new(File::create(file_path)?);
400        file.write_all(toml::to_string_pretty(&self)?.as_bytes())?;
401        Ok(())
402    }
403
404    /// Sends a custom event to Sentry with optional file attachment.
405    ///
406    /// This function creates and uploads a Sentry event with a message and optional
407    /// data attachment. Useful for manually reporting errors or uploading diagnostic data.
408    ///
409    /// # Arguments
410    ///
411    /// * `sentry_guard` - The Sentry client guard (must be active)
412    /// * `level` - Severity level (e.g., [`Level::Info`], [`Level::Warning`], [`Level::Error`])
413    /// * `message` - Event message/description
414    /// * `data` - Optional tuple of `(filename, data_bytes)` to attach to the event
415    ///
416    /// # Returns
417    ///
418    /// Returns [`Ok`] if the event was sent (or Sentry is disabled), or an error on failure.
419    ///
420    /// # Note
421    ///
422    /// If Sentry is not enabled (debug builds or no DSN), this function does nothing
423    /// and returns [`Ok`].
424    ///
425    /// # Example
426    ///
427    /// ```no_run
428    /// # use rpfm_telemetry::{Logger, Level};
429    /// # fn example(sentry_guard: &sentry::ClientInitGuard) -> Result<(), Box<dyn std::error::Error>> {
430    /// // Send a simple event
431    /// Logger::send_event(sentry_guard, Level::Info, "Schema updated", None)?;
432    ///
433    /// // Send an event with attachment
434    /// let patch_data = b"some patch data";
435    /// Logger::send_event(
436    ///     sentry_guard,
437    ///     Level::Warning,
438    ///     "Schema patch failed",
439    ///     Some(("patch.json", patch_data))
440    /// )?;
441    /// # Ok(())
442    /// # }
443    /// ```
444    pub fn send_event(sentry_guard: &ClientInitGuard, level: Level, message: &str, data: Option<(&str, &[u8])>) -> Result<()> {
445        if sentry_guard.is_enabled() {
446            let mut event = Event::new();
447            event.level = level;
448            event.message = Some(message.to_string());
449
450            let mut envelope = Envelope::from(event);
451            if let Some((filename, buffer)) = data {
452                let attatchment = Attachment {
453                    buffer: buffer.to_vec(),
454                    filename: filename.to_owned(),
455                    content_type: Some("application/json".to_owned()),
456                    ty: None
457                };
458
459                envelope.add_item(EnvelopeItem::Attachment(attatchment));
460            }
461            sentry_guard.send_envelope(envelope);
462        }
463
464        // TODO: Make this fail in case of sentry being not working?
465        Ok(())
466    }
467
468    /// Uploads schema patches to Sentry for debugging/analysis.
469    ///
470    /// Serializes the data to RON format and sends it as an informational Sentry event.
471    ///
472    /// # Arguments
473    ///
474    /// * `sentry_guard` - The Sentry client guard
475    /// * `game_name` - Name of the game the patches are for
476    /// * `patches` - The data to upload (must implement `Serialize`)
477    pub fn upload_patches(sentry_guard: &ClientInitGuard, game_name: &str, patches: &impl serde::Serialize) -> Result<()> {
478        let current_time = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
479        let level = Level::Info;
480        let message = format!("Schema Patch for: {} - {}.", game_name, current_time);
481        let config = PrettyConfig::default();
482        let mut data = vec![];
483        ron::ser::to_writer_pretty(&mut data, patches, config)?;
484        let file_name = "patch.txt";
485
486        Self::send_event(sentry_guard, level, &message, Some((file_name, &data)))
487    }
488
489    /// Uploads schema definitions to Sentry for debugging/analysis.
490    ///
491    /// Serializes the data to RON format and sends it as an informational Sentry event.
492    ///
493    /// # Arguments
494    ///
495    /// * `sentry_guard` - The Sentry client guard
496    /// * `game_name` - Name of the game the definitions are for
497    /// * `definitions` - The data to upload (must implement `Serialize`)
498    pub fn upload_definitions(sentry_guard: &ClientInitGuard, game_name: &str, definitions: &impl serde::Serialize) -> Result<()> {
499        let current_time = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
500        let level = Level::Info;
501        let message = format!("Schema Definition for: {} - {}.", game_name, current_time);
502        let config = PrettyConfig::default();
503        let mut data = vec![];
504        ron::ser::to_writer_pretty(&mut data, definitions, config)?;
505        let file_name = "definition.txt";
506
507        Self::send_event(sentry_guard, level, &message, Some((file_name, &data)))
508    }
509}
510
511/// Enables or disables automatic Sentry crash-report uploads.
512///
513/// Callers should refresh this whenever the `enable_crash_reports` setting
514/// changes.
515pub fn set_crash_reports_enabled(enabled: bool) {
516    CRASH_REPORTS_ENABLED.store(enabled, Ordering::Relaxed);
517}
518
519/// Returns the current crash-reports enabled state.
520pub fn is_crash_reports_enabled() -> bool {
521    CRASH_REPORTS_ENABLED.load(Ordering::Relaxed)
522}
523
524/// Returns `true` when the running build is a beta (patch number at or above
525/// [`BETA_PATCH_THRESHOLD`]). All workspace crates share the same version so
526/// this answer applies to the whole product regardless of which crate calls.
527pub const fn is_beta() -> bool {
528    VERSION_PATCH >= BETA_PATCH_THRESHOLD
529}