diff --git a/Cargo.lock b/Cargo.lock index e3eb75215..3be3329d5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4558,7 +4558,6 @@ dependencies = [ "rustls 0.23.32", "rusty-money", "sentry", - "sentry-actix", "serde", "serde_json", "serde_with", @@ -5061,6 +5060,7 @@ version = "0.0.0" dependencies = [ "dotenvy", "eyre", + "sentry", "tracing", "tracing-ecs", "tracing-subscriber", @@ -7846,19 +7846,6 @@ dependencies = [ "ureq", ] -[[package]] -name = "sentry-actix" -version = "0.45.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc694e6ffc8d5d7fdb2a33923b0358f6ad41c0b428ced034b349b9e2b08260bc" -dependencies = [ - "actix-http", - "actix-web", - "bytes", - "futures-util", - "sentry-core", -] - [[package]] name = "sentry-backtrace" version = "0.45.0" diff --git a/Cargo.toml b/Cargo.toml index 7de5f1e0c..f49a75fb4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -150,7 +150,6 @@ sentry = { version = "0.45.0", default-features = false, features = [ "reqwest", "rustls", ] } -sentry-actix = "0.45.0" serde = "1.0.228" serde_bytes = "0.11.19" serde_cbor = "0.11.2" @@ -239,7 +238,7 @@ manual_assert = "warn" manual_instant_elapsed = "warn" manual_is_variant_and = "warn" manual_let_else = "warn" -map_unwrap_or = "warn" +map_unwrap_or = "allow" match_bool = "warn" needless_collect = "warn" negative_feature_names = "warn" diff --git a/apps/labrinth/.env.docker-compose b/apps/labrinth/.env.docker-compose index 1f8ab58d9..311e77c96 100644 --- a/apps/labrinth/.env.docker-compose +++ b/apps/labrinth/.env.docker-compose @@ -1,6 +1,8 @@ DEBUG=true RUST_LOG=info,sqlx::query=warn SENTRY_DSN=none +SENTRY_ENVIRONMENT=development +SENTRY_TRACES_SAMPLE_RATE=0.1 SITE_URL=http://localhost:3000 # This CDN URL matches the local storage backend set below, which uses MOCK_FILE_PATH diff --git a/apps/labrinth/.env.local b/apps/labrinth/.env.local index 10d1d2514..ef9887a0c 100644 --- a/apps/labrinth/.env.local +++ b/apps/labrinth/.env.local @@ -1,6 +1,8 @@ DEBUG=true RUST_LOG=info,sqlx::query=warn SENTRY_DSN=none +SENTRY_ENVIRONMENT=development +SENTRY_TRACES_SAMPLE_RATE=0.1 SITE_URL=http://localhost:3000 # This CDN URL matches the local storage backend set below, which uses MOCK_FILE_PATH diff --git a/apps/labrinth/Cargo.toml b/apps/labrinth/Cargo.toml index 90d129d9a..cc0a3dc75 100644 --- a/apps/labrinth/Cargo.toml +++ b/apps/labrinth/Cargo.toml @@ -12,7 +12,7 @@ path = "src/main.rs" [dependencies] actix-cors = { workspace = true } actix-files = { workspace = true } -actix-http = { workspace = true, optional = true } +actix-http = { workspace = true } actix-multipart = { workspace = true } actix-rt = { workspace = true } actix-web = { workspace = true } @@ -70,7 +70,7 @@ itertools = { workspace = true } json-patch = { workspace = true } lettre = { workspace = true } meilisearch-sdk = { workspace = true, features = ["reqwest"] } -modrinth-util = { workspace = true, features = ["decimal", "utoipa"] } +modrinth-util = { workspace = true, features = ["decimal", "sentry", "utoipa"] } muralpay = { workspace = true, features = ["client", "mock", "utoipa"] } murmur2 = { workspace = true } paste = { workspace = true } @@ -95,7 +95,6 @@ rust-s3 = { workspace = true } rustls.workspace = true rusty-money = { workspace = true } sentry = { workspace = true } -sentry-actix = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } serde_with = { workspace = true } @@ -149,7 +148,7 @@ tikv-jemallocator = { workspace = true, features = [ ] } [features] -test = ["dep:actix-http"] +test = [] [lints] workspace = true diff --git a/apps/labrinth/src/auth/validate.rs b/apps/labrinth/src/auth/validate.rs index 325adc3d5..97abe100f 100644 --- a/apps/labrinth/src/auth/validate.rs +++ b/apps/labrinth/src/auth/validate.rs @@ -71,6 +71,7 @@ where Ok((scopes, db_user)) } +#[tracing::instrument(skip(req, executor, redis, session_queue))] pub async fn get_user_from_headers<'a, E>( req: &HttpRequest, executor: E, diff --git a/apps/labrinth/src/database/redis.rs b/apps/labrinth/src/database/redis.rs index 4dd570f1b..02f0fe59b 100644 --- a/apps/labrinth/src/database/redis.rs +++ b/apps/labrinth/src/database/redis.rs @@ -128,6 +128,7 @@ impl RedisPool { Ok(()) } + #[tracing::instrument(skip(self))] pub async fn connect(&self) -> Result { Ok(RedisConnection { connection: self.pool.get().await?, @@ -135,6 +136,7 @@ impl RedisPool { }) } + #[tracing::instrument(skip(self, closure))] pub async fn get_cached_keys( &self, namespace: &str, @@ -162,6 +164,7 @@ impl RedisPool { .collect()) } + #[tracing::instrument(skip(self, closure))] pub async fn get_cached_keys_raw( &self, namespace: &str, @@ -197,6 +200,7 @@ impl RedisPool { .await } + #[tracing::instrument(skip(self, closure))] pub async fn get_cached_keys_with_slug( &self, namespace: &str, @@ -233,6 +237,7 @@ impl RedisPool { .collect()) } + #[tracing::instrument(skip(self, closure))] pub async fn get_cached_keys_raw_with_slug( &self, namespace: &str, @@ -585,6 +590,7 @@ impl RedisPool { } impl RedisConnection { + #[tracing::instrument(skip(self))] pub async fn set( &mut self, namespace: &str, @@ -607,6 +613,7 @@ impl RedisConnection { Ok(()) } + #[tracing::instrument(skip(self, id, data))] pub async fn set_serialized_to_json( &mut self, namespace: &str, @@ -627,6 +634,7 @@ impl RedisConnection { .await } + #[tracing::instrument(skip(self))] pub async fn get( &mut self, namespace: &str, @@ -642,6 +650,7 @@ impl RedisConnection { Ok(res) } + #[tracing::instrument(skip(self))] pub async fn get_many( &mut self, namespace: &str, @@ -659,6 +668,7 @@ impl RedisConnection { Ok(res) } + #[tracing::instrument(skip(self))] pub async fn get_deserialized_from_json( &mut self, namespace: &str, @@ -673,6 +683,7 @@ impl RedisConnection { .and_then(|x| serde_json::from_str(&x).ok())) } + #[tracing::instrument(skip(self))] pub async fn get_many_deserialized_from_json( &mut self, namespace: &str, @@ -689,6 +700,7 @@ impl RedisConnection { .collect::>()) } + #[tracing::instrument(skip(self, id))] pub async fn delete( &mut self, namespace: &str, @@ -707,6 +719,7 @@ impl RedisConnection { Ok(()) } + #[tracing::instrument(skip(self, iter))] pub async fn delete_many( &mut self, iter: impl IntoIterator)>, @@ -731,6 +744,7 @@ impl RedisConnection { Ok(()) } + #[tracing::instrument(skip(self, value))] pub async fn lpush( &mut self, namespace: &str, @@ -742,6 +756,7 @@ impl RedisConnection { Ok(()) } + #[tracing::instrument(skip(self))] pub async fn brpop( &mut self, namespace: &str, diff --git a/apps/labrinth/src/lib.rs b/apps/labrinth/src/lib.rs index ae6a5e900..4376f2c20 100644 --- a/apps/labrinth/src/lib.rs +++ b/apps/labrinth/src/lib.rs @@ -375,6 +375,8 @@ pub fn check_env_vars() -> bool { check } + failed |= check_var::("SENTRY_ENVIRONMENT"); + failed |= check_var::("SENTRY_TRACES_SAMPLE_RATE"); failed |= check_var::("SITE_URL"); failed |= check_var::("CDN_URL"); failed |= check_var::("LABRINTH_ADMIN_KEY"); diff --git a/apps/labrinth/src/main.rs b/apps/labrinth/src/main.rs index 14f82dbbd..f785fc0aa 100644 --- a/apps/labrinth/src/main.rs +++ b/apps/labrinth/src/main.rs @@ -54,10 +54,7 @@ struct Args { run_background_task: Option, } -#[actix_rt::main] -async fn main() -> std::io::Result<()> { - let args = Args::parse(); - +fn main() -> std::io::Result<()> { color_eyre::install().expect("failed to install `color-eyre`"); dotenvy::dotenv().ok(); modrinth_util::log::init().expect("failed to initialize logging"); @@ -67,15 +64,17 @@ async fn main() -> std::io::Result<()> { std::process::exit(1); } - rustls::crypto::aws_lc_rs::default_provider() - .install_default() - .unwrap(); - + // Sentry must be set up before the async runtime is started + // // DSN is from SENTRY_DSN env variable. // Has no effect if not set. let sentry = sentry::init(sentry::ClientOptions { release: sentry::release_name!(), - traces_sample_rate: 0.1, + traces_sample_rate: dotenvy::var("SENTRY_TRACES_SAMPLE_RATE") + .unwrap() + .parse() + .expect("failed to parse `SENTRY_TRACES_SAMPLE_RATE` as number"), + environment: Some(dotenvy::var("SENTRY_ENVIRONMENT").unwrap().into()), ..Default::default() }); if sentry.is_enabled() { @@ -85,6 +84,20 @@ async fn main() -> std::io::Result<()> { } } + actix_rt::System::new().block_on(app())?; + + // Sentry guard must live until the end of the app + drop(sentry); + Ok(()) +} + +async fn app() -> std::io::Result<()> { + let args = Args::parse(); + + rustls::crypto::aws_lc_rs::default_provider() + .install_default() + .unwrap(); + if args.run_background_task.is_none() { info!( "Starting labrinth on {}", @@ -245,7 +258,12 @@ async fn main() -> std::io::Result<()> { .wrap(prometheus.clone()) .wrap(from_fn(rate_limit_middleware)) .wrap(actix_web::middleware::Compress::default()) - .wrap(sentry_actix::Sentry::new()) + // Sentry integration + // `sentry_actix::Sentry` provides an Actix middleware for making + // transactions out of HTTP requests. However, we have to use our + // own - See `sentry::SentryErrorReporting` for why. + .wrap(labrinth::util::sentry::SentryErrorReporting) + // Use `utoipa` for OpenAPI generation .into_utoipa_app() .configure(|cfg| utoipa_app_config(cfg, labrinth_config.clone())) .openapi_service(|api| SwaggerUi::new("/docs/swagger-ui/{_:.*}") @@ -280,11 +298,11 @@ impl utoipa::Modify for SecurityAddon { fn log_error(err: &actix_web::Error) { if err.as_response_error().status_code().is_client_error() { tracing::debug!( - "Error encountered while processing the incoming HTTP request: {err:#?}" + "Error encountered while processing the incoming HTTP request: {err:#}" ); } else { tracing::error!( - "Error encountered while processing the incoming HTTP request: {err:#?}" + "Error encountered while processing the incoming HTTP request: {err:#}" ); } } diff --git a/apps/labrinth/src/util/mod.rs b/apps/labrinth/src/util/mod.rs index fa9075001..cf44795ac 100644 --- a/apps/labrinth/src/util/mod.rs +++ b/apps/labrinth/src/util/mod.rs @@ -16,5 +16,6 @@ pub mod ip; pub mod ratelimit; pub mod redis; pub mod routes; +pub mod sentry; pub mod validate; pub mod webhook; diff --git a/apps/labrinth/src/util/sentry.rs b/apps/labrinth/src/util/sentry.rs new file mode 100644 index 000000000..dfd233bd5 --- /dev/null +++ b/apps/labrinth/src/util/sentry.rs @@ -0,0 +1,380 @@ +// large parts are copied from +// +// +// TODO: PR something into sentry_actix to let us customize this + +use std::{borrow::Cow, pin::Pin, rc::Rc}; + +use actix_http::{ + StatusCode, + header::{self, HeaderMap}, +}; +use actix_web::dev::{Service, ServiceRequest, ServiceResponse, Transform}; +use bytes::{Bytes, BytesMut}; +use futures::{ + FutureExt, TryStreamExt, + future::{Ready, ok}, +}; +use sentry::{ + Hub, MaxRequestBodySize, SentryFutureExt, + protocol::{self, ClientSdkPackage, Event, Request}, +}; + +use crate::routes::ApiError; + +/// Captures errors and reports them to Sentry. +/// +/// This rips out the error reporting logic from [`sentry_actix::Sentry`] and +/// customizes the logic to report errors with a proper stack trace. +/// +/// Since the error type of responses is [`actix_web::Error`], which implements +/// [`std::error::Error`] by always returning `None` for the source, the +/// reported error will always have no real error stack, which makes Sentry +/// issues a lot less useful. We fix this by manually converting the error to +/// a type which does have a proper error stack. +#[derive(Clone)] +pub struct SentryErrorReporting; + +impl Transform for SentryErrorReporting +where + S: Service< + ServiceRequest, + Response = ServiceResponse, + Error = actix_web::Error, + > + 'static, + S::Future: 'static, +{ + type Response = ServiceResponse; + type Error = actix_web::Error; + type Transform = SentryErrorMiddleware; + type InitError = (); + type Future = Ready>; + + fn new_transform(&self, service: S) -> Self::Future { + ok(SentryErrorMiddleware { + service: Rc::new(service), + }) + } +} + +pub struct SentryErrorMiddleware { + service: Rc, +} + +impl Service for SentryErrorMiddleware +where + S: Service< + ServiceRequest, + Response = ServiceResponse, + Error = actix_web::Error, + > + 'static, + S::Future: 'static, +{ + type Response = ServiceResponse; + type Error = actix_web::Error; + type Future = + Pin>>>; + + fn poll_ready( + &self, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + self.service.poll_ready(cx) + } + + fn call(&self, req: ServiceRequest) -> Self::Future { + let hub = Hub::current(); + let client = hub.client(); + + let max_request_body_size = client + .as_ref() + .map(|client| client.options().max_request_body_size) + .unwrap_or(MaxRequestBodySize::None); + + let with_pii = client + .as_ref() + .is_some_and(|client| client.options().send_default_pii); + + let mut sentry_req = sentry_request_from_http(&req, with_pii); + let name = transaction_name_from_http(&req); + + let transaction = { + let headers = req.headers().iter().filter_map(|(header, value)| { + value.to_str().ok().map(|value| (header.as_str(), value)) + }); + + let ctx = sentry::TransactionContext::continue_from_headers( + &name, + "http.server", + headers, + ); + + let transaction = hub.start_transaction(ctx); + transaction.set_request(sentry_req.clone()); + transaction.set_origin("auto.http.actix"); + transaction + }; + + let svc = self.service.clone(); + async move { + let mut req = req; + + if should_capture_request_body( + req.headers(), + with_pii, + max_request_body_size, + ) { + sentry_req.data = Some(capture_request_body(&mut req).await); + } + + let parent_span = hub.configure_scope(|scope| { + let parent_span = scope.get_span(); + scope.set_span(Some(transaction.clone().into())); + scope.add_event_processor(move |event| { + Some(process_event(event, &sentry_req)) + }); + parent_span + }); + + let fut = + Hub::run(hub.clone(), || svc.call(req)).bind_hub(hub.clone()); + let res: Self::Response = match fut.await { + Ok(res) => res, + Err(actix_err) => { + if actix_err.error_response().status().is_server_error() { + capture_downcasted_error(&hub, &actix_err); + } + + if transaction.get_status().is_none() { + let status = protocol::SpanStatus::UnknownError; + transaction.set_status(status); + } + transaction.finish(); + hub.configure_scope(|scope| scope.set_span(parent_span)); + return Err(actix_err); + } + }; + + // Response errors + if res.response().status().is_server_error() + && let Some(actix_err) = res.response().error() + { + capture_downcasted_error(&hub, actix_err); + } + + if transaction.get_status().is_none() { + let status = map_status(res.status()); + transaction.set_status(status); + } + transaction.finish(); + hub.configure_scope(|scope| scope.set_span(parent_span)); + + Ok(res) + } + .boxed_local() + } +} + +/// Converts an [`actix_web::Error`] into an error which implements +/// [`std::error::Error`] properly, so that Sentry can capture its error stack. +/// +/// If the underlying error is of a supported type like [`ApiError`], the error +/// stack will be properly captured. Otherwise, we use some error types to +/// still print the full stack trace, but "improperly". This is due to +/// limitations with Actix boxing the errors and type erasure. +fn capture_downcasted_error(hub: &Hub, actix_err: &actix_web::Error) { + #[derive(Debug, thiserror::Error)] + #[error("(note: error stack missing since it is of an unsupported type)")] + struct ErrorStackMissing; + + #[derive(Debug, thiserror::Error)] + #[error("{msg}")] + struct UnknownApiError { + msg: String, + source: ErrorStackMissing, + } + + if let Some(real_err) = actix_err.as_error::() { + hub.capture_error(real_err); + } else { + // due to type erasure, we can't downcast `err`'s underlying error to + // an error type from which we can fetch stacktrace + // and, due to type erasure, we don't even know its type name - how sad! + // use `:#` format to print the error chain, not just the first one + let err = UnknownApiError { + msg: format!("{actix_err:#}"), + source: ErrorStackMissing, + }; + hub.capture_error(&err); + } +} + +/// Extract a transaction name from the HTTP request +fn transaction_name_from_http(req: &ServiceRequest) -> String { + let path_part = req.match_pattern().unwrap_or_else(|| "".to_string()); + format!("{} {}", req.method(), path_part) +} + +/// Build a Sentry request struct from the HTTP request +fn sentry_request_from_http( + request: &ServiceRequest, + with_pii: bool, +) -> Request { + let mut sentry_req = Request { + url: format!( + "{}://{}{}", + request.connection_info().scheme(), + request.connection_info().host(), + request.uri() + ) + .parse() + .ok() + .map(scrub_pii_from_url), + method: Some(request.method().to_string()), + headers: request + .headers() + .iter() + .filter(|(_, v)| !v.is_sensitive()) + .filter(|(k, _)| with_pii || !is_sensitive_header(k.as_str())) + .map(|(k, v)| { + (k.to_string(), v.to_str().unwrap_or_default().to_string()) + }) + .collect(), + ..Default::default() + }; + + // If PII is enabled, include the remote address + if with_pii && let Some(remote) = request.connection_info().peer_addr() { + sentry_req.env.insert("REMOTE_ADDR".into(), remote.into()); + }; + + sentry_req +} + +/// Scrub PII (username and password) from the given URL. +pub fn scrub_pii_from_url(mut url: url::Url) -> url::Url { + // the set calls will fail and return an error if the URL is relative + // in those cases, just ignore the errors + if !url.username().is_empty() { + let _ = url.set_username(PII_REPLACEMENT); + } + if url.password().is_some() { + let _ = url.set_password(Some(PII_REPLACEMENT)); + } + url +} + +async fn capture_request_body(req: &mut ServiceRequest) -> String { + match body_from_http(req).await { + Ok(request_body) => String::from_utf8_lossy(&request_body).into_owned(), + Err(_) => String::new(), + } +} + +/// Extract a body from the HTTP request +async fn body_from_http(req: &mut ServiceRequest) -> actix_web::Result { + let stream = req.extract::().await?; + let body = stream.try_collect::().await?.freeze(); + + // put copy of payload back into request for downstream to read + req.set_payload(actix_web::dev::Payload::from(body.clone())); + + Ok(body) +} + +/// Add request data to a Sentry event +fn process_event( + mut event: Event<'static>, + request: &Request, +) -> Event<'static> { + // Request + if event.request.is_none() { + event.request = Some(request.clone()); + } + + // SDK + if let Some(sdk) = event.sdk.take() { + let mut sdk = sdk.into_owned(); + sdk.packages.push(ClientSdkPackage { + name: "sentry-actix".into(), + version: env!("CARGO_PKG_VERSION").into(), + }); + event.sdk = Some(Cow::Owned(sdk)); + } + event +} + +const SENSITIVE_HEADERS_UPPERCASE: &[&str] = &[ + "AUTHORIZATION", + "PROXY_AUTHORIZATION", + "COOKIE", + "SET_COOKIE", + "X_FORWARDED_FOR", + "X_REAL_IP", + "X_API_KEY", +]; + +const PII_REPLACEMENT: &str = "[Filtered]"; + +/// Determines if the HTTP header with the given name shall be considered as potentially carrying +/// sensitive data. +pub fn is_sensitive_header(name: &str) -> bool { + SENSITIVE_HEADERS_UPPERCASE + .contains(&name.to_ascii_uppercase().replace("-", "_").as_str()) +} + +fn should_capture_request_body( + headers: &HeaderMap, + with_pii: bool, + max_request_body_size: MaxRequestBodySize, +) -> bool { + let is_chunked = headers + .get(header::TRANSFER_ENCODING) + .and_then(|h| h.to_str().ok()) + .map(|transfer_encoding| transfer_encoding.contains("chunked")) + .unwrap_or(false); + + let is_valid_content_type = with_pii + || headers + .get(header::CONTENT_TYPE) + .and_then(|h| h.to_str().ok()) + .is_some_and(|content_type| { + matches!( + content_type, + "application/json" | "application/x-www-form-urlencoded" + ) + }); + + let is_within_size_limit = headers + .get(header::CONTENT_LENGTH) + .and_then(|h| h.to_str().ok()) + .and_then(|content_length| content_length.parse::().ok()) + .map(|content_length| { + max_request_body_size.is_within_size_limit(content_length) + }) + .unwrap_or(false); + + !is_chunked && is_valid_content_type && is_within_size_limit +} + +fn map_status(status: StatusCode) -> protocol::SpanStatus { + match status { + StatusCode::UNAUTHORIZED => protocol::SpanStatus::Unauthenticated, + StatusCode::FORBIDDEN => protocol::SpanStatus::PermissionDenied, + StatusCode::NOT_FOUND => protocol::SpanStatus::NotFound, + StatusCode::TOO_MANY_REQUESTS => { + protocol::SpanStatus::ResourceExhausted + } + status if status.is_client_error() => { + protocol::SpanStatus::InvalidArgument + } + StatusCode::NOT_IMPLEMENTED => protocol::SpanStatus::Unimplemented, + StatusCode::SERVICE_UNAVAILABLE => protocol::SpanStatus::Unavailable, + status if status.is_server_error() => { + protocol::SpanStatus::InternalError + } + StatusCode::CONFLICT => protocol::SpanStatus::AlreadyExists, + status if status.is_success() => protocol::SpanStatus::Ok, + _ => protocol::SpanStatus::UnknownError, + } +} diff --git a/packages/modrinth-log/Cargo.toml b/packages/modrinth-log/Cargo.toml index 83ce980d3..962b7660f 100644 --- a/packages/modrinth-log/Cargo.toml +++ b/packages/modrinth-log/Cargo.toml @@ -7,9 +7,13 @@ repository.workspace = true [dependencies] dotenvy = { workspace = true } eyre = { workspace = true } +sentry = { workspace = true, optional = true, features = ["tracing"] } tracing = { workspace = true } tracing-ecs = { workspace = true } tracing-subscriber = { workspace = true } +[features] +sentry = ["dep:sentry"] + [lints] workspace = true diff --git a/packages/modrinth-log/src/lib.rs b/packages/modrinth-log/src/lib.rs index 316377d5b..e463e2ad8 100644 --- a/packages/modrinth-log/src/lib.rs +++ b/packages/modrinth-log/src/lib.rs @@ -3,6 +3,8 @@ use std::str::FromStr; use eyre::{Result, WrapErr, eyre}; +#[cfg(feature = "sentry")] +use tracing::Level; use tracing::level_filters::LevelFilter; use tracing_ecs::ECSLayerBuilder; use tracing_subscriber::{ @@ -71,25 +73,50 @@ pub fn init_with_config(compact: bool) -> Result<()> { .with_default_directive(LevelFilter::INFO.into()) .from_env_lossy(); - let result = match (output_format, compact) { - (OutputFormat::Human, false) => tracing_subscriber::registry() - .with(env_filter) - .with(tracing_subscriber::fmt::layer()) - .try_init(), - (OutputFormat::Human, true) => tracing_subscriber::registry() - .with(env_filter) - .with( + let (layer1, layer2, layer3) = match (output_format, compact) { + (OutputFormat::Human, false) => { + (Some(tracing_subscriber::fmt::layer()), None, None) + } + (OutputFormat::Human, true) => ( + None, + Some( tracing_subscriber::fmt::layer() .without_time() .with_target(false), - ) - .try_init(), - (OutputFormat::Json, _) => tracing_subscriber::registry() - .with(env_filter) - .with(ECSLayerBuilder::default().stdout()) - .try_init(), + ), + None, + ), + (OutputFormat::Json, _) => { + (None, None, Some(ECSLayerBuilder::default().stdout())) + } }; - result.wrap_err("failed to initialize tracing registry")?; + + let registry = tracing_subscriber::registry(); + + let registry = registry + .with(env_filter) + .with(layer1) + .with(layer2) + .with(layer3); + + // Add Sentry layer but don't capture any events from tracing events, just breadcrumbs + #[cfg(feature = "sentry")] + let registry = registry.with( + sentry::integrations::tracing::SentryLayer::default().event_filter( + |metadata| match *metadata.level() { + Level::ERROR | Level::WARN | Level::INFO => { + sentry::integrations::tracing::EventFilter::Breadcrumb + } + Level::DEBUG | Level::TRACE => { + sentry::integrations::tracing::EventFilter::empty() + } + }, + ), + ); + + registry + .try_init() + .wrap_err("failed to initialize tracing registry")?; Ok(()) } diff --git a/packages/modrinth-util/Cargo.toml b/packages/modrinth-util/Cargo.toml index b461dc282..91c06d90f 100644 --- a/packages/modrinth-util/Cargo.toml +++ b/packages/modrinth-util/Cargo.toml @@ -20,6 +20,7 @@ serde_json = { workspace = true } [features] decimal = ["dep:rust_decimal", "utoipa?/decimal"] utoipa = ["dep:utoipa"] +sentry = ["modrinth-log/sentry"] [lints] workspace = true