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}