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::actions::{TELEMETRY_EVENT_TAG, TELEMETRY_EVENT_VALUE, USAGE_TELEMETRY_ENABLED};
100use crate::feedback::FEEDBACK_EVENT_VALUE;
101use crate::{error, info, warn};
102
103/// Current version of the crate from Cargo.toml.
104const VERSION: &str = env!("CARGO_PKG_VERSION");
105
106/// Patch version of the current crate, parsed from `CARGO_PKG_VERSION_PATCH`.
107///
108/// rpfm uses the patch number as the beta marker: a value `>=` [`BETA_PATCH_THRESHOLD`]
109/// identifies a beta build (e.g. `4.7.106` is a beta, `4.7.0` is stable).
110const VERSION_PATCH: u64 = match u64::from_str_radix(env!("CARGO_PKG_VERSION_PATCH"), 10) {
111    Ok(v) => v,
112    Err(_) => panic!("CARGO_PKG_VERSION_PATCH is not a valid u64"),
113};
114
115/// Patch number at or above which a build is considered a beta release.
116const BETA_PATCH_THRESHOLD: u64 = 99;
117
118/// Sentry environment for stable releases.
119const SENTRY_ENV_PRODUCTION: &str = "production";
120
121/// Sentry environment for beta builds (patch `>=` [`BETA_PATCH_THRESHOLD`]).
122const SENTRY_ENV_BETA: &str = "beta";
123
124/// Sentry DSN (Data Source Name) for error reporting.
125///
126/// This must be set before calling [`Logger::init()`] for Sentry integration to work.
127/// The DSN is provided by Sentry when creating a project.
128pub static SENTRY_DSN: LazyLock<Arc<RwLock<String>>> = LazyLock::new(|| Arc::new(RwLock::new(String::new())));
129
130/// Whether automatic Sentry crash reports (panics, auto-captured errors,
131/// session tracking) are allowed to be uploaded. On by default so panics
132/// during early startup (before settings are loaded) are still captured;
133/// callers may opt out via [`set_crash_reports_enabled`] after reading the
134/// `enable_crash_reports` setting.
135///
136/// This is checked from the `before_send` hook installed in [`Logger::init`],
137/// so toggling it at runtime takes effect immediately for all future events.
138static CRASH_REPORTS_ENABLED: AtomicBool = AtomicBool::new(true);
139
140/// Enables or disables automatic Sentry crash-report uploads.
141///
142/// This gates panic reports and any other event captured automatically by
143/// Sentry's integrations. Usage-telemetry events (flushed via
144/// [`crate::flush`]) are exempt and honour
145/// [`crate::set_usage_telemetry_enabled`] instead.
146///
147/// Callers should refresh this whenever the `enable_crash_reports` setting
148/// changes.
149pub fn set_crash_reports_enabled(enabled: bool) {
150    CRASH_REPORTS_ENABLED.store(enabled, Ordering::Relaxed);
151}
152
153/// Returns the current crash-reports enabled state.
154pub fn is_crash_reports_enabled() -> bool {
155    CRASH_REPORTS_ENABLED.load(Ordering::Relaxed)
156}
157
158//-------------------------------------------------------------------------------//
159//                              Enums & Structs
160//-------------------------------------------------------------------------------//
161
162/// Error type for the logging crate.
163#[derive(Debug, thiserror::Error)]
164pub enum LogError {
165
166    /// Wrapper for [`std::io::Error`].
167    #[error(transparent)]
168    IoError(#[from] std::io::Error),
169
170    /// Wrapper for [`toml::ser::Error`].
171    #[error(transparent)]
172    TomlSerError(#[from] toml::ser::Error),
173
174    /// Wrapper for [`log::SetLoggerError`].
175    #[error(transparent)]
176    LogError(#[from] log::SetLoggerError),
177
178    /// Wrapper for [`ron::Error`].
179    #[error(transparent)]
180    RonError(#[from] ron::Error),
181
182    /// Wrapper for [`std::time::SystemTimeError`].
183    #[error(transparent)]
184    SystemTimeError(#[from] std::time::SystemTimeError),
185}
186
187/// Result type for the logging crate.
188pub type Result<T> = std::result::Result<T, LogError>;
189
190/// Crash report data structure.
191///
192/// Contains all information needed to generate a detailed crash report that can be
193/// saved locally as a TOML file or uploaded to Sentry. Created automatically when
194/// a panic occurs.
195#[derive(Debug, Serialize)]
196pub struct Logger {
197
198    /// Name of the program that crashed.
199    ///
200    /// Taken from the `CARGO_PKG_NAME` environment variable.
201    name: String,
202
203    /// Version of the program/library.
204    ///
205    /// Taken from the `CARGO_PKG_VERSION` environment variable.
206    crate_version: String,
207
208    /// Build configuration (Debug or Release).
209    ///
210    /// Determined at compile time based on debug assertions.
211    build_type: String,
212
213    /// Operating system information.
214    ///
215    /// Includes OS type and version (e.g., "Windows 11", "Ubuntu 22.04").
216    operating_system: String,
217
218    /// Panic explanation.
219    ///
220    /// Contains the panic message and location (file and line number).
221    explanation: String,
222
223    /// Full backtrace from the panic.
224    ///
225    /// Formatted stack trace showing the call chain leading to the panic.
226    backtrace: String,
227}
228
229//-------------------------------------------------------------------------------//
230//                              Implementations
231//-------------------------------------------------------------------------------//
232
233/// Implementation of `Logger`.
234impl Logger {
235
236    /// Initializes the logging system with crash reporting and Sentry integration.
237    ///
238    /// This function sets up three logging mechanisms:
239    /// 1. **Local crash reports**: Panics are saved as TOML files to disk
240    /// 2. **Sentry crash reporting**: Panics are uploaded to Sentry (release builds only)
241    /// 3. **Runtime logging**: Structured logging via terminal and Sentry breadcrumbs
242    ///
243    /// # Arguments
244    ///
245    /// * `logging_path` - Directory where crash reports will be saved
246    /// * `verbose` - If `true`, log `Info` level messages; if `false`, only `Warn` and above
247    /// * `set_logger` - If `true`, initialize the global logger (disable for testing)
248    /// * `release` - Optional release identifier for Sentry (e.g., `"rpfm@5.0.0"`)
249    ///
250    /// # Returns
251    ///
252    /// Returns a [`ClientInitGuard`] that must be kept alive for the duration of the program.
253    /// Dropping the guard will shut down Sentry and flush pending events.
254    ///
255    /// # Panics
256    ///
257    /// After initialization, any panic in any thread will:
258    /// 1. Generate a local crash report in `logging_path`
259    /// 2. Upload to Sentry (if in release mode and enabled)
260    /// 3. Mark the Sentry session as crashed
261    ///
262    /// # Example
263    ///
264    /// ```no_run
265    /// # use rpfm_telemetry::Logger;
266    /// # use std::path::Path;
267    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
268    /// let _guard = Logger::init(
269    ///     Path::new("crash_reports"),
270    ///     true,  // verbose
271    ///     true,  // set global logger
272    ///     Some("myapp@1.0.0".into())
273    /// )?;
274    ///
275    /// // Logger is now active
276    /// // Keep _guard alive until program exit
277    /// # Ok(())
278    /// # }
279    /// ```
280    pub fn init(logging_path: &Path, verbose: bool, set_logger: bool, release: Option<Cow<'static, str>>) -> Result<ClientInitGuard> {
281
282        // Make sure the provided folder exists.
283        if let Some(parent_folder) = logging_path.parent() {
284            DirBuilder::new().recursive(true).create(parent_folder)?;
285        }
286
287        let log_level = if verbose {
288            LevelFilter::Info
289        } else {
290            LevelFilter::Warn
291        };
292
293        if set_logger {
294
295            // Initialize the combined logger, with a term logger (for runtime logging) and a write logger (for storing on a log file).
296            //
297            // 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.
298            let loggers: Vec<Box<dyn SharedLogger + 'static>> = vec![TermLogger::new(log_level, simplelog::Config::default(), TerminalMode::Mixed, ColorChoice::Auto)];
299            let combined_logger = CombinedLogger::new(loggers);
300
301            // Initialize Sentry's logger, so anything logged goes to the breadcrumbs too.
302            let logger = SentryLogger::with_dest(combined_logger);
303            log::set_max_level(log_level);
304            let _ = log::set_boxed_logger(Box::new(logger));
305        }
306
307        // Initialize Sentry's guard, for remote reporting. Only for release mode.
308        let dsn = SENTRY_DSN.read().unwrap().to_string();
309
310        // Gate every Sentry event on the user-facing toggles. Usage-telemetry
311        // events (tagged by `crate::flush`) honour `enable_usage_telemetry`;
312        // user-feedback events (tagged by `crate::send_user_feedback`) always
313        // pass through because the user just clicked Send and that is consent
314        // in the moment; anything else (panics, auto-captured errors,
315        // sessions) honours `enable_crash_reports`. Returning `None` drops
316        // the event.
317        let before_send = Arc::new(|event: Event<'static>| -> Option<Event<'static>> {
318            let kind = event.tags.get(TELEMETRY_EVENT_TAG).map(String::as_str);
319
320            let allowed = match kind {
321                Some(TELEMETRY_EVENT_VALUE) => USAGE_TELEMETRY_ENABLED.load(Ordering::Relaxed),
322                Some(FEEDBACK_EVENT_VALUE) => true,
323                _ => CRASH_REPORTS_ENABLED.load(Ordering::Relaxed),
324            };
325
326            if allowed { Some(event) } else { None }
327        });
328
329        // Route beta builds into a separate Sentry environment so beta noise
330        // doesn't pollute production crash trends. rpfm marks betas by bumping
331        // the patch number to `>= BETA_PATCH_THRESHOLD` (e.g. 4.7.106).
332        let environment = if VERSION_PATCH >= BETA_PATCH_THRESHOLD {
333            Cow::Borrowed(SENTRY_ENV_BETA)
334        } else {
335            Cow::Borrowed(SENTRY_ENV_PRODUCTION)
336        };
337
338        let client_options = ClientOptions {
339            release: release.clone(),
340            environment: Some(environment),
341            sample_rate: 0.3,
342            traces_sample_rate: 0.3,
343            enable_logs: true,
344            auto_session_tracking: true,
345            session_mode: SessionMode::Application,
346            before_send: Some(before_send),
347            ..Default::default()
348        };
349
350        let sentry_guard = sentry::init((dsn, client_options));
351
352        // Setup the panic hooks to catch panics on all threads, not only the main one.
353        let sentry_enabled = sentry_guard.is_enabled();
354        let orig_hook = panic::take_hook();
355        let logging_path = logging_path.to_owned();
356        panic::set_hook(Box::new(move |info: &PanicHookInfo| {
357            warn!("Panic detected. Generating backtraces and crash logs...");
358
359            // Get the data printed into the logs, because I'm tired of this getting "missed" when is a cross-thread crash.
360            let data = Self::new(info, VERSION);
361            if data.save(&logging_path).is_err() {
362                error!("Failed to generate crash log.");
363            }
364
365            orig_hook(info);
366
367            // Stop tracking session health before existing.
368            if sentry_enabled {
369                end_session_with_status(SessionStatus::Crashed)
370            }
371        }));
372
373        // Return Sentry's guard, so we can keep it alive until everything explodes, or the user closes the program.
374        info!("Logger initialized.");
375        Ok(sentry_guard)
376    }
377
378    /// Creates a crash report from panic information.
379    ///
380    /// This function extracts all relevant information from a panic and constructs
381    /// a structured crash report. The report is created in memory and must be
382    /// explicitly saved with [`Logger::save()`].
383    ///
384    /// # Arguments
385    ///
386    /// * `panic_info` - Panic hook information provided by the panic handler
387    /// * `version` - Version string of the program
388    ///
389    /// # Returns
390    ///
391    /// Returns a populated [`Logger`] instance containing the crash report data.
392    ///
393    /// # Note
394    ///
395    /// This is typically called automatically by the panic hook installed by [`Logger::init()`].
396    pub fn new(panic_info: &PanicHookInfo, version: &str) -> Self {
397
398        let info = os_info::get();
399        let operating_system = format!("OS: {}\nVersion: {}", info.os_type(), info.version());
400
401        let mut explanation = String::new();
402        if let Some(payload) = panic_info.payload().downcast_ref::<&str>() {
403            explanation.push_str(&format!("Cause: {}\n", &payload));
404        }
405
406        match panic_info.location() {
407            Some(location) => explanation.push_str(&format!("Panic occurred in file '{}' at line {}\n", location.file(), location.line())),
408            None => explanation.push_str("Panic location unknown.\n"),
409        }
410
411        Self {
412            name: env!("CARGO_PKG_NAME").to_owned(),
413            crate_version: version.to_owned(),
414            build_type: if cfg!(debug_assertions) { "Debug" } else { "Release" }.to_owned(),
415            operating_system,
416            explanation,
417            backtrace: format!("{:#?}", Backtrace::new()),
418        }
419    }
420
421    /// Saves the crash report to a TOML file.
422    ///
423    /// The crash report is saved with a timestamped filename in the format
424    /// `error-report-{timestamp}.toml`.
425    ///
426    /// # Arguments
427    ///
428    /// * `path` - Directory where the crash report file should be saved
429    ///
430    /// # Returns
431    ///
432    /// Returns [`Ok`] if the report was saved successfully, or an error if file I/O fails.
433    pub fn save(&self, path: &Path) -> Result<()> {
434        let current_time = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
435        let file_path = path.join(format!("error-report-{}.toml", current_time));
436        let mut file = BufWriter::new(File::create(file_path)?);
437        file.write_all(toml::to_string_pretty(&self)?.as_bytes())?;
438        Ok(())
439    }
440
441    /// Sends a custom event to Sentry with optional file attachment.
442    ///
443    /// This function creates and uploads a Sentry event with a message and optional
444    /// data attachment. Useful for manually reporting errors or uploading diagnostic data.
445    ///
446    /// # Arguments
447    ///
448    /// * `sentry_guard` - The Sentry client guard (must be active)
449    /// * `level` - Severity level (e.g., [`Level::Info`], [`Level::Warning`], [`Level::Error`])
450    /// * `message` - Event message/description
451    /// * `data` - Optional tuple of `(filename, data_bytes)` to attach to the event
452    ///
453    /// # Returns
454    ///
455    /// Returns [`Ok`] if the event was sent (or Sentry is disabled), or an error on failure.
456    ///
457    /// # Note
458    ///
459    /// If Sentry is not enabled (debug builds or no DSN), this function does nothing
460    /// and returns [`Ok`].
461    ///
462    /// # Example
463    ///
464    /// ```no_run
465    /// # use rpfm_telemetry::{Logger, Level};
466    /// # fn example(sentry_guard: &sentry::ClientInitGuard) -> Result<(), Box<dyn std::error::Error>> {
467    /// // Send a simple event
468    /// Logger::send_event(sentry_guard, Level::Info, "Schema updated", None)?;
469    ///
470    /// // Send an event with attachment
471    /// let patch_data = b"some patch data";
472    /// Logger::send_event(
473    ///     sentry_guard,
474    ///     Level::Warning,
475    ///     "Schema patch failed",
476    ///     Some(("patch.json", patch_data))
477    /// )?;
478    /// # Ok(())
479    /// # }
480    /// ```
481    pub fn send_event(sentry_guard: &ClientInitGuard, level: Level, message: &str, data: Option<(&str, &[u8])>) -> Result<()> {
482        if sentry_guard.is_enabled() {
483            let mut event = Event::new();
484            event.level = level;
485            event.message = Some(message.to_string());
486
487            let mut envelope = Envelope::from(event);
488            if let Some((filename, buffer)) = data {
489                let attatchment = Attachment {
490                    buffer: buffer.to_vec(),
491                    filename: filename.to_owned(),
492                    content_type: Some("application/json".to_owned()),
493                    ty: None
494                };
495
496                envelope.add_item(EnvelopeItem::Attachment(attatchment));
497            }
498            sentry_guard.send_envelope(envelope);
499        }
500
501        // TODO: Make this fail in case of sentry being not working?
502        Ok(())
503    }
504
505    /// Uploads schema patches to Sentry for debugging/analysis.
506    ///
507    /// Serializes the data to RON format and sends it as an informational Sentry event.
508    ///
509    /// # Arguments
510    ///
511    /// * `sentry_guard` - The Sentry client guard
512    /// * `game_name` - Name of the game the patches are for
513    /// * `patches` - The data to upload (must implement `Serialize`)
514    pub fn upload_patches(sentry_guard: &ClientInitGuard, game_name: &str, patches: &impl serde::Serialize) -> Result<()> {
515        let current_time = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
516        let level = Level::Info;
517        let message = format!("Schema Patch for: {} - {}.", game_name, current_time);
518        let config = PrettyConfig::default();
519        let mut data = vec![];
520        ron::ser::to_writer_pretty(&mut data, patches, config)?;
521        let file_name = "patch.txt";
522
523        Self::send_event(sentry_guard, level, &message, Some((file_name, &data)))
524    }
525
526    /// Uploads schema definitions to Sentry for debugging/analysis.
527    ///
528    /// Serializes the data to RON format and sends it as an informational Sentry event.
529    ///
530    /// # Arguments
531    ///
532    /// * `sentry_guard` - The Sentry client guard
533    /// * `game_name` - Name of the game the definitions are for
534    /// * `definitions` - The data to upload (must implement `Serialize`)
535    pub fn upload_definitions(sentry_guard: &ClientInitGuard, game_name: &str, definitions: &impl serde::Serialize) -> Result<()> {
536        let current_time = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
537        let level = Level::Info;
538        let message = format!("Schema Definition for: {} - {}.", game_name, current_time);
539        let config = PrettyConfig::default();
540        let mut data = vec![];
541        ron::ser::to_writer_pretty(&mut data, definitions, config)?;
542        let file_name = "definition.txt";
543
544        Self::send_event(sentry_guard, level, &message, Some((file_name, &data)))
545    }
546}