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}