chore: better sentry error reporting regarding stacktraces and errors
This commit is contained in:
parent
07884b9894
commit
6fb841bc06
11 changed files with 269 additions and 64 deletions
21
.github/workflows/oneclient_nightly.yml
vendored
21
.github/workflows/oneclient_nightly.yml
vendored
|
|
@ -95,6 +95,27 @@ jobs:
|
|||
echo "dir=target/release" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
# Upload debug info (dSYM / .dwp / .pdb produced by the release profile's
|
||||
# split-debuginfo) to Sentry so nightly crash reports get real file/function/
|
||||
# line stack traces instead of raw addresses. Runs on every main push, so
|
||||
# each unique build's symbols land in Sentry (matched by debug-id). Skips
|
||||
# silently unless SENTRY_AUTH_TOKEN secret + SENTRY_ORG/SENTRY_PROJECT vars
|
||||
# are configured.
|
||||
- name: Upload debug symbols to Sentry
|
||||
shell: bash
|
||||
env:
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
SENTRY_ORG: ${{ vars.SENTRY_ORG }}
|
||||
SENTRY_PROJECT: ${{ vars.SENTRY_PROJECT }}
|
||||
OUT_DIR: ${{ steps.outdir.outputs.dir }}
|
||||
run: |
|
||||
if [ -z "${SENTRY_AUTH_TOKEN:-}" ] || [ -z "${SENTRY_ORG:-}" ] || [ -z "${SENTRY_PROJECT:-}" ]; then
|
||||
echo "Sentry credentials not configured (need SENTRY_AUTH_TOKEN secret + SENTRY_ORG/SENTRY_PROJECT vars); skipping debug-symbol upload."
|
||||
exit 0
|
||||
fi
|
||||
curl -sL https://sentry.io/get-cli/ | bash
|
||||
sentry-cli debug-files upload --include-sources "$OUT_DIR"
|
||||
|
||||
- name: Collect installers
|
||||
id: collect
|
||||
shell: bash
|
||||
|
|
|
|||
19
.github/workflows/oneclient_release.yml
vendored
19
.github/workflows/oneclient_release.yml
vendored
|
|
@ -167,6 +167,25 @@ jobs:
|
|||
echo "dir=target/release" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
# Upload debug info (dSYM / .dwp / .pdb produced by the release profile's
|
||||
# split-debuginfo) to Sentry so crash reports get real file/function/line
|
||||
# stack traces instead of raw addresses. Skips silently unless the repo has
|
||||
# a SENTRY_AUTH_TOKEN secret plus SENTRY_ORG / SENTRY_PROJECT variables.
|
||||
- name: Upload debug symbols to Sentry
|
||||
shell: bash
|
||||
env:
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
SENTRY_ORG: ${{ vars.SENTRY_ORG }}
|
||||
SENTRY_PROJECT: ${{ vars.SENTRY_PROJECT }}
|
||||
OUT_DIR: ${{ steps.outdir.outputs.dir }}
|
||||
run: |
|
||||
if [ -z "${SENTRY_AUTH_TOKEN:-}" ] || [ -z "${SENTRY_ORG:-}" ] || [ -z "${SENTRY_PROJECT:-}" ]; then
|
||||
echo "Sentry credentials not configured (need SENTRY_AUTH_TOKEN secret + SENTRY_ORG/SENTRY_PROJECT vars); skipping debug-symbol upload."
|
||||
exit 0
|
||||
fi
|
||||
curl -sL https://sentry.io/get-cli/ | bash
|
||||
sentry-cli debug-files upload --include-sources "$OUT_DIR"
|
||||
|
||||
- name: (Windows) Locate installer
|
||||
if: matrix.platform == 'windows-latest'
|
||||
id: win_installer
|
||||
|
|
|
|||
|
|
@ -137,9 +137,16 @@ freya = { git = "https://github.com/marc2332/freya.git", branch = "fix/blur" }
|
|||
opt-level = 3
|
||||
lto = "fat"
|
||||
codegen-units = 1
|
||||
# Keep the *shipped* binary stripped and small, but still emit debug info into
|
||||
# separate files (macOS: .dSYM, Linux: .dwp, Windows: .pdb) so Sentry can
|
||||
# symbolicate crash reports. CI uploads those files with `sentry-cli debug-files
|
||||
# upload`; they are never shipped to users. Without this, release stack traces
|
||||
# are just raw addresses. Use "full" instead of "line-tables-only" if you also
|
||||
# want inlined-frame / variable info (larger debug files, slower builds).
|
||||
strip = true
|
||||
panic = "abort"
|
||||
debug = false
|
||||
debug = "line-tables-only"
|
||||
split-debuginfo = "packed"
|
||||
|
||||
[profile.release-profiling]
|
||||
inherits = "release"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
use std::cmp::Ordering;
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use chrono::{Datelike, NaiveDate};
|
||||
use oneclient_core::clusters::Cluster;
|
||||
|
|
|
|||
|
|
@ -8,7 +8,8 @@ use oneclient_core::notification::{
|
|||
GroupedProgressEvent, GroupedProgressSession, Notification, NotificationService,
|
||||
};
|
||||
use oneclient_core::{
|
||||
BundleArchive, BundleFile, Cluster, ImportTarget, MigrationSource, VersionMetadata,
|
||||
BundleArchive, BundleFile, Cluster, ImportTarget, MigrationSource, SentryExclusion,
|
||||
VersionMetadata,
|
||||
};
|
||||
use oneclient_db::models::OverrideType;
|
||||
use tokio::sync::mpsc;
|
||||
|
|
@ -718,13 +719,28 @@ fn run_install_batch(plans: Vec<ClusterPlan>, predownload: bool, handles: Instal
|
|||
}));
|
||||
|
||||
let fetch_now = predownload && plan.predownload;
|
||||
if let Err(reason) = install_one(&plan, fetch_now, ¬ifier, &ui_tx).await {
|
||||
if let Err(err) = install_one(&plan, fetch_now, ¬ifier, &ui_tx).await {
|
||||
// Environmental / expected failures (out of disk, offline, no Java
|
||||
// build for this version, ...) are surfaced to the user but aren't
|
||||
// crashes, so log them at warn! (breadcrumb) instead of error!
|
||||
// (a reported Sentry event). See LauncherError::is_sentry_excluded.
|
||||
if err.is_sentry_excluded() {
|
||||
tracing::warn!(
|
||||
cluster_id = plan.cluster_id,
|
||||
mc_version = %plan.mc_version,
|
||||
"onboarding install failed (expected): {err}"
|
||||
);
|
||||
} else {
|
||||
tracing::error!(
|
||||
cluster_id = plan.cluster_id,
|
||||
mc_version = %plan.mc_version,
|
||||
"onboarding install failed: {reason}"
|
||||
"onboarding install failed: {err}"
|
||||
);
|
||||
failed.push(InstallFailure { plan, reason });
|
||||
}
|
||||
failed.push(InstallFailure {
|
||||
plan,
|
||||
reason: err.to_string(),
|
||||
});
|
||||
}
|
||||
let _ = ui_tx.send(InstallUiEvent::Progress(index + 1, total));
|
||||
}
|
||||
|
|
@ -739,8 +755,8 @@ async fn install_one(
|
|||
predownload: bool,
|
||||
notifier: &NotificationService,
|
||||
ui_tx: &mpsc::UnboundedSender<InstallUiEvent>,
|
||||
) -> Result<(), String> {
|
||||
let state = oneclient_core::LauncherState::get().map_err(|err| err.to_string())?;
|
||||
) -> oneclient_core::LauncherResult<()> {
|
||||
let state = oneclient_core::LauncherState::get()?;
|
||||
|
||||
if !plan.overrides.is_empty() {
|
||||
let _ = ui_tx.send(InstallUiEvent::Activity(
|
||||
|
|
@ -748,8 +764,7 @@ async fn install_one(
|
|||
));
|
||||
}
|
||||
oneclient_core::set_bundle_package_overrides(plan.cluster_id, &plan.overrides, &state.services)
|
||||
.await
|
||||
.map_err(|err| err.to_string())?;
|
||||
.await?;
|
||||
|
||||
if !predownload {
|
||||
return Ok(());
|
||||
|
|
@ -791,8 +806,8 @@ async fn install_one(
|
|||
|
||||
session.finish();
|
||||
|
||||
prepared.map_err(|err| err.to_string())?;
|
||||
bundles_result.map_err(|err| err.to_string())?;
|
||||
prepared?;
|
||||
bundles_result?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -787,7 +787,7 @@ async fn parse_json_response<T: for<'de> Deserialize<'de>>(
|
|||
.map_err(|source| MinecraftAuthError::RequestError { step, source })?;
|
||||
|
||||
if !status.is_success() {
|
||||
tracing::error!("[auth] step={step:?} status={status} body={text}");
|
||||
tracing::error!(sentry = false, ?step, %status, body = %text, "MSA endpoint returned a non-success status");
|
||||
}
|
||||
|
||||
serde_json::from_str(&text).map_err(|source| {
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ pub enum LauncherError {
|
|||
JsonError(#[from] serde_json::Error),
|
||||
|
||||
#[error("Database execution failed: {0}")]
|
||||
DatabaseError(#[from] sqlx::Error),
|
||||
SqlError(#[from] sqlx::Error),
|
||||
|
||||
#[error(transparent)]
|
||||
IoError(#[from] polyio::IOError),
|
||||
|
|
@ -81,4 +81,75 @@ impl LauncherError {
|
|||
}
|
||||
}
|
||||
|
||||
pub trait SentryExclusion {
|
||||
/// Whether this error is expected/environmental noise that should be kept out
|
||||
/// of Sentry rather than reported as a crash.
|
||||
fn is_sentry_excluded(&self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
impl SentryExclusion for LauncherError {
|
||||
fn is_sentry_excluded(&self) -> bool {
|
||||
match self {
|
||||
LauncherError::StdIoError(e) => e.is_sentry_excluded(),
|
||||
LauncherError::IoError(e) => e.is_sentry_excluded(),
|
||||
LauncherError::RequestError(e) => e.is_sentry_excluded(),
|
||||
LauncherError::JavaError(e) => e.is_sentry_excluded(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SentryExclusion for std::io::Error {
|
||||
fn is_sentry_excluded(&self) -> bool {
|
||||
use std::io::ErrorKind;
|
||||
|
||||
// Out of disk space. `ErrorKind::StorageFull` is still unstable, so match
|
||||
// the raw OS codes instead. Codes are platform-gated so a Unix errno can't
|
||||
// collide with a Windows code (112 is the unrelated EHOSTDOWN on Linux).
|
||||
if let Some(code) = self.raw_os_error() {
|
||||
#[cfg(unix)]
|
||||
if code == 28 {
|
||||
// ENOSPC
|
||||
return true;
|
||||
}
|
||||
#[cfg(windows)]
|
||||
if code == 112 || code == 39 {
|
||||
// ERROR_DISK_FULL / ERROR_HANDLE_DISK_FULL
|
||||
return true;
|
||||
}
|
||||
let _ = code;
|
||||
}
|
||||
|
||||
// Lost/refused network connections during downloads.
|
||||
matches!(
|
||||
self.kind(),
|
||||
ErrorKind::ConnectionRefused
|
||||
| ErrorKind::ConnectionReset
|
||||
| ErrorKind::ConnectionAborted
|
||||
| ErrorKind::NotConnected
|
||||
| ErrorKind::TimedOut
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl SentryExclusion for reqwest::Error {
|
||||
fn is_sentry_excluded(&self) -> bool {
|
||||
// Connectivity problems (offline, connection refused, timed out) are the
|
||||
// user's network, not a launcher bug.
|
||||
self.is_timeout() || self.is_connect()
|
||||
}
|
||||
}
|
||||
|
||||
impl SentryExclusion for polyio::IOError {
|
||||
fn is_sentry_excluded(&self) -> bool {
|
||||
match self {
|
||||
polyio::IOError::IOError(source)
|
||||
| polyio::IOError::PathIOError { source, .. } => source.is_sentry_excluded(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub type LauncherResult<T> = Result<T, LauncherError>;
|
||||
|
|
|
|||
|
|
@ -45,3 +45,15 @@ pub enum RequestError {
|
|||
#[error("Invalid HTTP header value: {0}")]
|
||||
InvalidHeaderValue(#[from] reqwest::header::InvalidHeaderValue),
|
||||
}
|
||||
|
||||
impl crate::error::SentryExclusion for RequestError {
|
||||
fn is_sentry_excluded(&self) -> bool {
|
||||
// Only connectivity / disk failures are noise; malformed requests, bad
|
||||
// URLs, and unexpected response shapes are bugs worth reporting.
|
||||
match self {
|
||||
RequestError::ReqwestError(source) => source.is_sentry_excluded(),
|
||||
RequestError::IOError(source) => source.is_sentry_excluded(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -41,3 +41,18 @@ pub enum JavaError {
|
|||
#[error("failed to extract archive '{archive}'")]
|
||||
ArchiveExtractFailed { archive: String },
|
||||
}
|
||||
|
||||
impl crate::error::SentryExclusion for JavaError {
|
||||
fn is_sentry_excluded(&self) -> bool {
|
||||
match self {
|
||||
// Expected outcomes: no build exists for the requested version, or the
|
||||
// user cancelled setup.
|
||||
JavaError::PackageNotFound { .. } | JavaError::Cancelled => true,
|
||||
// Environmental IO (e.g. out of disk while extracting the runtime).
|
||||
JavaError::RuntimeCheckError { source, .. }
|
||||
| JavaError::ArchiveExtract { source, .. } => source.is_sentry_excluded(),
|
||||
JavaError::PolyIOError(source) => source.is_sentry_excluded(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -54,7 +54,7 @@ pub use clusters::{
|
|||
Cluster, ClusterError, ClusterManager, ClusterStage, ClusterUpdate, CreateClusterOptions,
|
||||
ensure_from_bundles, ensure_from_versions, estimate_cluster_download,
|
||||
};
|
||||
pub use error::{LauncherError, LauncherResult};
|
||||
pub use error::{LauncherError, LauncherResult, SentryExclusion};
|
||||
pub use game::{GameError, LaunchedGame, get_loader_versions, launch_cluster};
|
||||
pub use images::ImageCacheStore;
|
||||
pub use logs::{
|
||||
|
|
|
|||
|
|
@ -1,11 +1,14 @@
|
|||
use std::panic;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use sentry::protocol::{Context, Event};
|
||||
use sentry::{ClientInitGuard, ClientOptions};
|
||||
|
||||
use crate::constants::SENTRY_DSN;
|
||||
|
||||
const PANIC_FLUSH_TIMEOUT: Duration = Duration::from_secs(2);
|
||||
const TRACING_FIELDS_CONTEXT: &str = "Rust Tracing Fields";
|
||||
|
||||
const ENVIRONMENT: &str = if cfg!(debug_assertions) {
|
||||
"development"
|
||||
|
|
@ -13,6 +16,22 @@ const ENVIRONMENT: &str = if cfg!(debug_assertions) {
|
|||
"production"
|
||||
};
|
||||
|
||||
/// `before_send` hook: drop any event a log call explicitly opted out of by
|
||||
/// setting the `sentry` field to `false`, e.g.
|
||||
///
|
||||
/// ```ignore
|
||||
/// tracing::error!(sentry = false, "handled MSA failure, don't report as a crash");
|
||||
/// ```
|
||||
fn drop_opted_out_events(event: Event<'static>) -> Option<Event<'static>> {
|
||||
let opted_out = matches!(
|
||||
event.contexts.get(TRACING_FIELDS_CONTEXT),
|
||||
Some(Context::Other(fields))
|
||||
if fields.get("sentry").and_then(serde_json::Value::as_bool) == Some(false)
|
||||
);
|
||||
|
||||
if opted_out { None } else { Some(event) }
|
||||
}
|
||||
|
||||
pub fn init(enabled: bool) -> Option<ClientInitGuard> {
|
||||
if !enabled {
|
||||
tracing::debug!("crash reporting disabled by settings");
|
||||
|
|
@ -31,6 +50,7 @@ pub fn init(enabled: bool) -> Option<ClientInitGuard> {
|
|||
environment: Some(ENVIRONMENT.into()),
|
||||
attach_stacktrace: true,
|
||||
send_default_pii: false,
|
||||
before_send: Some(Arc::new(drop_opted_out_events)),
|
||||
..Default::default()
|
||||
},
|
||||
));
|
||||
|
|
@ -73,4 +93,30 @@ mod tests {
|
|||
fn opting_out_skips_the_client() {
|
||||
assert!(init(false).is_none());
|
||||
}
|
||||
|
||||
/// Build an event the way `sentry-tracing` would, given the value of a
|
||||
/// `sentry` field on the log call (or `None` for no such field).
|
||||
fn event_with_sentry_field(value: Option<bool>) -> Event<'static> {
|
||||
let mut event = Event::default();
|
||||
if let Some(value) = value {
|
||||
let mut fields = std::collections::BTreeMap::new();
|
||||
fields.insert("sentry".to_owned(), serde_json::Value::Bool(value));
|
||||
event
|
||||
.contexts
|
||||
.insert(TRACING_FIELDS_CONTEXT.to_owned(), Context::Other(fields));
|
||||
}
|
||||
event
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drops_events_flagged_sentry_false() {
|
||||
assert!(drop_opted_out_events(event_with_sentry_field(Some(false))).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_events_without_opt_out() {
|
||||
assert!(drop_opted_out_events(event_with_sentry_field(Some(true))).is_some());
|
||||
assert!(drop_opted_out_events(event_with_sentry_field(None)).is_some());
|
||||
assert!(drop_opted_out_events(Event::default()).is_some());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue