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 auto_session_tracking: true,
344 session_mode: SessionMode::Application,
345 before_send: Some(before_send),
346 ..Default::default()
347 };
348
349 let sentry_guard = sentry::init((dsn, client_options));
350
351 // Setup the panic hooks to catch panics on all threads, not only the main one.
352 let sentry_enabled = sentry_guard.is_enabled();
353 let orig_hook = panic::take_hook();
354 let logging_path = logging_path.to_owned();
355 panic::set_hook(Box::new(move |info: &PanicHookInfo| {
356 warn!("Panic detected. Generating backtraces and crash logs...");
357
358 // Get the data printed into the logs, because I'm tired of this getting "missed" when is a cross-thread crash.
359 let data = Self::new(info, VERSION);
360 if data.save(&logging_path).is_err() {
361 error!("Failed to generate crash log.");
362 }
363
364 orig_hook(info);
365
366 // Stop tracking session health before existing.
367 if sentry_enabled {
368 end_session_with_status(SessionStatus::Crashed)
369 }
370 }));
371
372 // Return Sentry's guard, so we can keep it alive until everything explodes, or the user closes the program.
373 info!("Logger initialized.");
374 Ok(sentry_guard)
375 }
376
377 /// Creates a crash report from panic information.
378 ///
379 /// This function extracts all relevant information from a panic and constructs
380 /// a structured crash report. The report is created in memory and must be
381 /// explicitly saved with [`Logger::save()`].
382 ///
383 /// # Arguments
384 ///
385 /// * `panic_info` - Panic hook information provided by the panic handler
386 /// * `version` - Version string of the program
387 ///
388 /// # Returns
389 ///
390 /// Returns a populated [`Logger`] instance containing the crash report data.
391 ///
392 /// # Note
393 ///
394 /// This is typically called automatically by the panic hook installed by [`Logger::init()`].
395 pub fn new(panic_info: &PanicHookInfo, version: &str) -> Self {
396
397 let info = os_info::get();
398 let operating_system = format!("OS: {}\nVersion: {}", info.os_type(), info.version());
399
400 let mut explanation = String::new();
401 if let Some(payload) = panic_info.payload().downcast_ref::<&str>() {
402 explanation.push_str(&format!("Cause: {}\n", &payload));
403 }
404
405 match panic_info.location() {
406 Some(location) => explanation.push_str(&format!("Panic occurred in file '{}' at line {}\n", location.file(), location.line())),
407 None => explanation.push_str("Panic location unknown.\n"),
408 }
409
410 Self {
411 name: env!("CARGO_PKG_NAME").to_owned(),
412 crate_version: version.to_owned(),
413 build_type: if cfg!(debug_assertions) { "Debug" } else { "Release" }.to_owned(),
414 operating_system,
415 explanation,
416 backtrace: format!("{:#?}", Backtrace::new()),
417 }
418 }
419
420 /// Saves the crash report to a TOML file.
421 ///
422 /// The crash report is saved with a timestamped filename in the format
423 /// `error-report-{timestamp}.toml`.
424 ///
425 /// # Arguments
426 ///
427 /// * `path` - Directory where the crash report file should be saved
428 ///
429 /// # Returns
430 ///
431 /// Returns [`Ok`] if the report was saved successfully, or an error if file I/O fails.
432 pub fn save(&self, path: &Path) -> Result<()> {
433 let current_time = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
434 let file_path = path.join(format!("error-report-{}.toml", current_time));
435 let mut file = BufWriter::new(File::create(file_path)?);
436 file.write_all(toml::to_string_pretty(&self)?.as_bytes())?;
437 Ok(())
438 }
439
440 /// Sends a custom event to Sentry with optional file attachment.
441 ///
442 /// This function creates and uploads a Sentry event with a message and optional
443 /// data attachment. Useful for manually reporting errors or uploading diagnostic data.
444 ///
445 /// # Arguments
446 ///
447 /// * `sentry_guard` - The Sentry client guard (must be active)
448 /// * `level` - Severity level (e.g., [`Level::Info`], [`Level::Warning`], [`Level::Error`])
449 /// * `message` - Event message/description
450 /// * `data` - Optional tuple of `(filename, data_bytes)` to attach to the event
451 ///
452 /// # Returns
453 ///
454 /// Returns [`Ok`] if the event was sent (or Sentry is disabled), or an error on failure.
455 ///
456 /// # Note
457 ///
458 /// If Sentry is not enabled (debug builds or no DSN), this function does nothing
459 /// and returns [`Ok`].
460 ///
461 /// # Example
462 ///
463 /// ```no_run
464 /// # use rpfm_telemetry::{Logger, Level};
465 /// # fn example(sentry_guard: &sentry::ClientInitGuard) -> Result<(), Box<dyn std::error::Error>> {
466 /// // Send a simple event
467 /// Logger::send_event(sentry_guard, Level::Info, "Schema updated", None)?;
468 ///
469 /// // Send an event with attachment
470 /// let patch_data = b"some patch data";
471 /// Logger::send_event(
472 /// sentry_guard,
473 /// Level::Warning,
474 /// "Schema patch failed",
475 /// Some(("patch.json", patch_data))
476 /// )?;
477 /// # Ok(())
478 /// # }
479 /// ```
480 pub fn send_event(sentry_guard: &ClientInitGuard, level: Level, message: &str, data: Option<(&str, &[u8])>) -> Result<()> {
481 if sentry_guard.is_enabled() {
482 let mut event = Event::new();
483 event.level = level;
484 event.message = Some(message.to_string());
485
486 let mut envelope = Envelope::from(event);
487 if let Some((filename, buffer)) = data {
488 let attatchment = Attachment {
489 buffer: buffer.to_vec(),
490 filename: filename.to_owned(),
491 content_type: Some("application/json".to_owned()),
492 ty: None
493 };
494
495 envelope.add_item(EnvelopeItem::Attachment(attatchment));
496 }
497 sentry_guard.send_envelope(envelope);
498 }
499
500 // TODO: Make this fail in case of sentry being not working?
501 Ok(())
502 }
503
504 /// Uploads schema patches to Sentry for debugging/analysis.
505 ///
506 /// Serializes the data to RON format and sends it as an informational Sentry event.
507 ///
508 /// # Arguments
509 ///
510 /// * `sentry_guard` - The Sentry client guard
511 /// * `game_name` - Name of the game the patches are for
512 /// * `patches` - The data to upload (must implement `Serialize`)
513 pub fn upload_patches(sentry_guard: &ClientInitGuard, game_name: &str, patches: &impl serde::Serialize) -> Result<()> {
514 let current_time = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
515 let level = Level::Info;
516 let message = format!("Schema Patch for: {} - {}.", game_name, current_time);
517 let config = PrettyConfig::default();
518 let mut data = vec![];
519 ron::ser::to_writer_pretty(&mut data, patches, config)?;
520 let file_name = "patch.txt";
521
522 Self::send_event(sentry_guard, level, &message, Some((file_name, &data)))
523 }
524
525 /// Uploads schema definitions to Sentry for debugging/analysis.
526 ///
527 /// Serializes the data to RON format and sends it as an informational Sentry event.
528 ///
529 /// # Arguments
530 ///
531 /// * `sentry_guard` - The Sentry client guard
532 /// * `game_name` - Name of the game the definitions are for
533 /// * `definitions` - The data to upload (must implement `Serialize`)
534 pub fn upload_definitions(sentry_guard: &ClientInitGuard, game_name: &str, definitions: &impl serde::Serialize) -> Result<()> {
535 let current_time = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
536 let level = Level::Info;
537 let message = format!("Schema Definition for: {} - {}.", game_name, current_time);
538 let config = PrettyConfig::default();
539 let mut data = vec![];
540 ron::ser::to_writer_pretty(&mut data, definitions, config)?;
541 let file_name = "definition.txt";
542
543 Self::send_event(sentry_guard, level, &message, Some((file_name, &data)))
544 }
545}