You've already forked AstralRinth
forked from xxxOFFxxx/AstralRinth
Improve Labrinth Sentry integration (#5174)
* Improve Sentry integration * remove debug routes * fix ci * sentry tracing stuff * Add spans to Sentry logging * Fix CI * Redis op instrumentation * pr comments
This commit is contained in:
15
Cargo.lock
generated
15
Cargo.lock
generated
@@ -4558,7 +4558,6 @@ dependencies = [
|
|||||||
"rustls 0.23.32",
|
"rustls 0.23.32",
|
||||||
"rusty-money",
|
"rusty-money",
|
||||||
"sentry",
|
"sentry",
|
||||||
"sentry-actix",
|
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_with",
|
"serde_with",
|
||||||
@@ -5061,6 +5060,7 @@ version = "0.0.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
"eyre",
|
"eyre",
|
||||||
|
"sentry",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-ecs",
|
"tracing-ecs",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
@@ -7846,19 +7846,6 @@ dependencies = [
|
|||||||
"ureq",
|
"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]]
|
[[package]]
|
||||||
name = "sentry-backtrace"
|
name = "sentry-backtrace"
|
||||||
version = "0.45.0"
|
version = "0.45.0"
|
||||||
|
|||||||
@@ -150,7 +150,6 @@ sentry = { version = "0.45.0", default-features = false, features = [
|
|||||||
"reqwest",
|
"reqwest",
|
||||||
"rustls",
|
"rustls",
|
||||||
] }
|
] }
|
||||||
sentry-actix = "0.45.0"
|
|
||||||
serde = "1.0.228"
|
serde = "1.0.228"
|
||||||
serde_bytes = "0.11.19"
|
serde_bytes = "0.11.19"
|
||||||
serde_cbor = "0.11.2"
|
serde_cbor = "0.11.2"
|
||||||
@@ -239,7 +238,7 @@ manual_assert = "warn"
|
|||||||
manual_instant_elapsed = "warn"
|
manual_instant_elapsed = "warn"
|
||||||
manual_is_variant_and = "warn"
|
manual_is_variant_and = "warn"
|
||||||
manual_let_else = "warn"
|
manual_let_else = "warn"
|
||||||
map_unwrap_or = "warn"
|
map_unwrap_or = "allow"
|
||||||
match_bool = "warn"
|
match_bool = "warn"
|
||||||
needless_collect = "warn"
|
needless_collect = "warn"
|
||||||
negative_feature_names = "warn"
|
negative_feature_names = "warn"
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
DEBUG=true
|
DEBUG=true
|
||||||
RUST_LOG=info,sqlx::query=warn
|
RUST_LOG=info,sqlx::query=warn
|
||||||
SENTRY_DSN=none
|
SENTRY_DSN=none
|
||||||
|
SENTRY_ENVIRONMENT=development
|
||||||
|
SENTRY_TRACES_SAMPLE_RATE=0.1
|
||||||
|
|
||||||
SITE_URL=http://localhost:3000
|
SITE_URL=http://localhost:3000
|
||||||
# This CDN URL matches the local storage backend set below, which uses MOCK_FILE_PATH
|
# This CDN URL matches the local storage backend set below, which uses MOCK_FILE_PATH
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
DEBUG=true
|
DEBUG=true
|
||||||
RUST_LOG=info,sqlx::query=warn
|
RUST_LOG=info,sqlx::query=warn
|
||||||
SENTRY_DSN=none
|
SENTRY_DSN=none
|
||||||
|
SENTRY_ENVIRONMENT=development
|
||||||
|
SENTRY_TRACES_SAMPLE_RATE=0.1
|
||||||
|
|
||||||
SITE_URL=http://localhost:3000
|
SITE_URL=http://localhost:3000
|
||||||
# This CDN URL matches the local storage backend set below, which uses MOCK_FILE_PATH
|
# This CDN URL matches the local storage backend set below, which uses MOCK_FILE_PATH
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ path = "src/main.rs"
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
actix-cors = { workspace = true }
|
actix-cors = { workspace = true }
|
||||||
actix-files = { workspace = true }
|
actix-files = { workspace = true }
|
||||||
actix-http = { workspace = true, optional = true }
|
actix-http = { workspace = true }
|
||||||
actix-multipart = { workspace = true }
|
actix-multipart = { workspace = true }
|
||||||
actix-rt = { workspace = true }
|
actix-rt = { workspace = true }
|
||||||
actix-web = { workspace = true }
|
actix-web = { workspace = true }
|
||||||
@@ -70,7 +70,7 @@ itertools = { workspace = true }
|
|||||||
json-patch = { workspace = true }
|
json-patch = { workspace = true }
|
||||||
lettre = { workspace = true }
|
lettre = { workspace = true }
|
||||||
meilisearch-sdk = { workspace = true, features = ["reqwest"] }
|
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"] }
|
muralpay = { workspace = true, features = ["client", "mock", "utoipa"] }
|
||||||
murmur2 = { workspace = true }
|
murmur2 = { workspace = true }
|
||||||
paste = { workspace = true }
|
paste = { workspace = true }
|
||||||
@@ -95,7 +95,6 @@ rust-s3 = { workspace = true }
|
|||||||
rustls.workspace = true
|
rustls.workspace = true
|
||||||
rusty-money = { workspace = true }
|
rusty-money = { workspace = true }
|
||||||
sentry = { workspace = true }
|
sentry = { workspace = true }
|
||||||
sentry-actix = { workspace = true }
|
|
||||||
serde = { workspace = true, features = ["derive"] }
|
serde = { workspace = true, features = ["derive"] }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
serde_with = { workspace = true }
|
serde_with = { workspace = true }
|
||||||
@@ -149,7 +148,7 @@ tikv-jemallocator = { workspace = true, features = [
|
|||||||
] }
|
] }
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
test = ["dep:actix-http"]
|
test = []
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ where
|
|||||||
Ok((scopes, db_user))
|
Ok((scopes, db_user))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(skip(req, executor, redis, session_queue))]
|
||||||
pub async fn get_user_from_headers<'a, E>(
|
pub async fn get_user_from_headers<'a, E>(
|
||||||
req: &HttpRequest,
|
req: &HttpRequest,
|
||||||
executor: E,
|
executor: E,
|
||||||
|
|||||||
@@ -128,6 +128,7 @@ impl RedisPool {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(skip(self))]
|
||||||
pub async fn connect(&self) -> Result<RedisConnection, DatabaseError> {
|
pub async fn connect(&self) -> Result<RedisConnection, DatabaseError> {
|
||||||
Ok(RedisConnection {
|
Ok(RedisConnection {
|
||||||
connection: self.pool.get().await?,
|
connection: self.pool.get().await?,
|
||||||
@@ -135,6 +136,7 @@ impl RedisPool {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(skip(self, closure))]
|
||||||
pub async fn get_cached_keys<F, Fut, T, K>(
|
pub async fn get_cached_keys<F, Fut, T, K>(
|
||||||
&self,
|
&self,
|
||||||
namespace: &str,
|
namespace: &str,
|
||||||
@@ -162,6 +164,7 @@ impl RedisPool {
|
|||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(skip(self, closure))]
|
||||||
pub async fn get_cached_keys_raw<F, Fut, T, K>(
|
pub async fn get_cached_keys_raw<F, Fut, T, K>(
|
||||||
&self,
|
&self,
|
||||||
namespace: &str,
|
namespace: &str,
|
||||||
@@ -197,6 +200,7 @@ impl RedisPool {
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(skip(self, closure))]
|
||||||
pub async fn get_cached_keys_with_slug<F, Fut, T, I, K, S>(
|
pub async fn get_cached_keys_with_slug<F, Fut, T, I, K, S>(
|
||||||
&self,
|
&self,
|
||||||
namespace: &str,
|
namespace: &str,
|
||||||
@@ -233,6 +237,7 @@ impl RedisPool {
|
|||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(skip(self, closure))]
|
||||||
pub async fn get_cached_keys_raw_with_slug<F, Fut, T, I, K, S>(
|
pub async fn get_cached_keys_raw_with_slug<F, Fut, T, I, K, S>(
|
||||||
&self,
|
&self,
|
||||||
namespace: &str,
|
namespace: &str,
|
||||||
@@ -585,6 +590,7 @@ impl RedisPool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl RedisConnection {
|
impl RedisConnection {
|
||||||
|
#[tracing::instrument(skip(self))]
|
||||||
pub async fn set(
|
pub async fn set(
|
||||||
&mut self,
|
&mut self,
|
||||||
namespace: &str,
|
namespace: &str,
|
||||||
@@ -607,6 +613,7 @@ impl RedisConnection {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(skip(self, id, data))]
|
||||||
pub async fn set_serialized_to_json<Id, D>(
|
pub async fn set_serialized_to_json<Id, D>(
|
||||||
&mut self,
|
&mut self,
|
||||||
namespace: &str,
|
namespace: &str,
|
||||||
@@ -627,6 +634,7 @@ impl RedisConnection {
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(skip(self))]
|
||||||
pub async fn get(
|
pub async fn get(
|
||||||
&mut self,
|
&mut self,
|
||||||
namespace: &str,
|
namespace: &str,
|
||||||
@@ -642,6 +650,7 @@ impl RedisConnection {
|
|||||||
Ok(res)
|
Ok(res)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(skip(self))]
|
||||||
pub async fn get_many(
|
pub async fn get_many(
|
||||||
&mut self,
|
&mut self,
|
||||||
namespace: &str,
|
namespace: &str,
|
||||||
@@ -659,6 +668,7 @@ impl RedisConnection {
|
|||||||
Ok(res)
|
Ok(res)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(skip(self))]
|
||||||
pub async fn get_deserialized_from_json<R>(
|
pub async fn get_deserialized_from_json<R>(
|
||||||
&mut self,
|
&mut self,
|
||||||
namespace: &str,
|
namespace: &str,
|
||||||
@@ -673,6 +683,7 @@ impl RedisConnection {
|
|||||||
.and_then(|x| serde_json::from_str(&x).ok()))
|
.and_then(|x| serde_json::from_str(&x).ok()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(skip(self))]
|
||||||
pub async fn get_many_deserialized_from_json<R>(
|
pub async fn get_many_deserialized_from_json<R>(
|
||||||
&mut self,
|
&mut self,
|
||||||
namespace: &str,
|
namespace: &str,
|
||||||
@@ -689,6 +700,7 @@ impl RedisConnection {
|
|||||||
.collect::<Vec<_>>())
|
.collect::<Vec<_>>())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(skip(self, id))]
|
||||||
pub async fn delete<T1>(
|
pub async fn delete<T1>(
|
||||||
&mut self,
|
&mut self,
|
||||||
namespace: &str,
|
namespace: &str,
|
||||||
@@ -707,6 +719,7 @@ impl RedisConnection {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(skip(self, iter))]
|
||||||
pub async fn delete_many(
|
pub async fn delete_many(
|
||||||
&mut self,
|
&mut self,
|
||||||
iter: impl IntoIterator<Item = (&str, Option<String>)>,
|
iter: impl IntoIterator<Item = (&str, Option<String>)>,
|
||||||
@@ -731,6 +744,7 @@ impl RedisConnection {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(skip(self, value))]
|
||||||
pub async fn lpush(
|
pub async fn lpush(
|
||||||
&mut self,
|
&mut self,
|
||||||
namespace: &str,
|
namespace: &str,
|
||||||
@@ -742,6 +756,7 @@ impl RedisConnection {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(skip(self))]
|
||||||
pub async fn brpop(
|
pub async fn brpop(
|
||||||
&mut self,
|
&mut self,
|
||||||
namespace: &str,
|
namespace: &str,
|
||||||
|
|||||||
@@ -375,6 +375,8 @@ pub fn check_env_vars() -> bool {
|
|||||||
check
|
check
|
||||||
}
|
}
|
||||||
|
|
||||||
|
failed |= check_var::<String>("SENTRY_ENVIRONMENT");
|
||||||
|
failed |= check_var::<String>("SENTRY_TRACES_SAMPLE_RATE");
|
||||||
failed |= check_var::<String>("SITE_URL");
|
failed |= check_var::<String>("SITE_URL");
|
||||||
failed |= check_var::<String>("CDN_URL");
|
failed |= check_var::<String>("CDN_URL");
|
||||||
failed |= check_var::<String>("LABRINTH_ADMIN_KEY");
|
failed |= check_var::<String>("LABRINTH_ADMIN_KEY");
|
||||||
|
|||||||
@@ -54,10 +54,7 @@ struct Args {
|
|||||||
run_background_task: Option<BackgroundTask>,
|
run_background_task: Option<BackgroundTask>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[actix_rt::main]
|
fn main() -> std::io::Result<()> {
|
||||||
async fn main() -> std::io::Result<()> {
|
|
||||||
let args = Args::parse();
|
|
||||||
|
|
||||||
color_eyre::install().expect("failed to install `color-eyre`");
|
color_eyre::install().expect("failed to install `color-eyre`");
|
||||||
dotenvy::dotenv().ok();
|
dotenvy::dotenv().ok();
|
||||||
modrinth_util::log::init().expect("failed to initialize logging");
|
modrinth_util::log::init().expect("failed to initialize logging");
|
||||||
@@ -67,15 +64,17 @@ async fn main() -> std::io::Result<()> {
|
|||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
rustls::crypto::aws_lc_rs::default_provider()
|
// Sentry must be set up before the async runtime is started
|
||||||
.install_default()
|
// <https://docs.sentry.io/platforms/rust/guides/actix-web/>
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
// DSN is from SENTRY_DSN env variable.
|
// DSN is from SENTRY_DSN env variable.
|
||||||
// Has no effect if not set.
|
// Has no effect if not set.
|
||||||
let sentry = sentry::init(sentry::ClientOptions {
|
let sentry = sentry::init(sentry::ClientOptions {
|
||||||
release: sentry::release_name!(),
|
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()
|
..Default::default()
|
||||||
});
|
});
|
||||||
if sentry.is_enabled() {
|
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() {
|
if args.run_background_task.is_none() {
|
||||||
info!(
|
info!(
|
||||||
"Starting labrinth on {}",
|
"Starting labrinth on {}",
|
||||||
@@ -245,7 +258,12 @@ async fn main() -> std::io::Result<()> {
|
|||||||
.wrap(prometheus.clone())
|
.wrap(prometheus.clone())
|
||||||
.wrap(from_fn(rate_limit_middleware))
|
.wrap(from_fn(rate_limit_middleware))
|
||||||
.wrap(actix_web::middleware::Compress::default())
|
.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()
|
.into_utoipa_app()
|
||||||
.configure(|cfg| utoipa_app_config(cfg, labrinth_config.clone()))
|
.configure(|cfg| utoipa_app_config(cfg, labrinth_config.clone()))
|
||||||
.openapi_service(|api| SwaggerUi::new("/docs/swagger-ui/{_:.*}")
|
.openapi_service(|api| SwaggerUi::new("/docs/swagger-ui/{_:.*}")
|
||||||
@@ -280,11 +298,11 @@ impl utoipa::Modify for SecurityAddon {
|
|||||||
fn log_error(err: &actix_web::Error) {
|
fn log_error(err: &actix_web::Error) {
|
||||||
if err.as_response_error().status_code().is_client_error() {
|
if err.as_response_error().status_code().is_client_error() {
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
"Error encountered while processing the incoming HTTP request: {err:#?}"
|
"Error encountered while processing the incoming HTTP request: {err:#}"
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
tracing::error!(
|
tracing::error!(
|
||||||
"Error encountered while processing the incoming HTTP request: {err:#?}"
|
"Error encountered while processing the incoming HTTP request: {err:#}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,5 +16,6 @@ pub mod ip;
|
|||||||
pub mod ratelimit;
|
pub mod ratelimit;
|
||||||
pub mod redis;
|
pub mod redis;
|
||||||
pub mod routes;
|
pub mod routes;
|
||||||
|
pub mod sentry;
|
||||||
pub mod validate;
|
pub mod validate;
|
||||||
pub mod webhook;
|
pub mod webhook;
|
||||||
|
|||||||
380
apps/labrinth/src/util/sentry.rs
Normal file
380
apps/labrinth/src/util/sentry.rs
Normal file
@@ -0,0 +1,380 @@
|
|||||||
|
// large parts are copied from
|
||||||
|
// <https://github.com/getsentry/sentry-rust/blob/99e1d9d9b78074a9a4c472fa7d2fc0f15c474a4b/sentry-actix/src/lib.rs>
|
||||||
|
//
|
||||||
|
// 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<S, B> Transform<S, ServiceRequest> for SentryErrorReporting
|
||||||
|
where
|
||||||
|
S: Service<
|
||||||
|
ServiceRequest,
|
||||||
|
Response = ServiceResponse<B>,
|
||||||
|
Error = actix_web::Error,
|
||||||
|
> + 'static,
|
||||||
|
S::Future: 'static,
|
||||||
|
{
|
||||||
|
type Response = ServiceResponse<B>;
|
||||||
|
type Error = actix_web::Error;
|
||||||
|
type Transform = SentryErrorMiddleware<S>;
|
||||||
|
type InitError = ();
|
||||||
|
type Future = Ready<Result<Self::Transform, Self::InitError>>;
|
||||||
|
|
||||||
|
fn new_transform(&self, service: S) -> Self::Future {
|
||||||
|
ok(SentryErrorMiddleware {
|
||||||
|
service: Rc::new(service),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct SentryErrorMiddleware<S> {
|
||||||
|
service: Rc<S>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<S, B> Service<ServiceRequest> for SentryErrorMiddleware<S>
|
||||||
|
where
|
||||||
|
S: Service<
|
||||||
|
ServiceRequest,
|
||||||
|
Response = ServiceResponse<B>,
|
||||||
|
Error = actix_web::Error,
|
||||||
|
> + 'static,
|
||||||
|
S::Future: 'static,
|
||||||
|
{
|
||||||
|
type Response = ServiceResponse<B>;
|
||||||
|
type Error = actix_web::Error;
|
||||||
|
type Future =
|
||||||
|
Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>>>>;
|
||||||
|
|
||||||
|
fn poll_ready(
|
||||||
|
&self,
|
||||||
|
cx: &mut std::task::Context<'_>,
|
||||||
|
) -> std::task::Poll<Result<(), Self::Error>> {
|
||||||
|
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::<ApiError>() {
|
||||||
|
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(|| "<none>".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<Bytes> {
|
||||||
|
let stream = req.extract::<actix_web::web::Payload>().await?;
|
||||||
|
let body = stream.try_collect::<BytesMut>().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::<usize>().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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,9 +7,13 @@ repository.workspace = true
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
dotenvy = { workspace = true }
|
dotenvy = { workspace = true }
|
||||||
eyre = { workspace = true }
|
eyre = { workspace = true }
|
||||||
|
sentry = { workspace = true, optional = true, features = ["tracing"] }
|
||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
tracing-ecs = { workspace = true }
|
tracing-ecs = { workspace = true }
|
||||||
tracing-subscriber = { workspace = true }
|
tracing-subscriber = { workspace = true }
|
||||||
|
|
||||||
|
[features]
|
||||||
|
sentry = ["dep:sentry"]
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
|
|
||||||
use eyre::{Result, WrapErr, eyre};
|
use eyre::{Result, WrapErr, eyre};
|
||||||
|
#[cfg(feature = "sentry")]
|
||||||
|
use tracing::Level;
|
||||||
use tracing::level_filters::LevelFilter;
|
use tracing::level_filters::LevelFilter;
|
||||||
use tracing_ecs::ECSLayerBuilder;
|
use tracing_ecs::ECSLayerBuilder;
|
||||||
use tracing_subscriber::{
|
use tracing_subscriber::{
|
||||||
@@ -71,25 +73,50 @@ pub fn init_with_config(compact: bool) -> Result<()> {
|
|||||||
.with_default_directive(LevelFilter::INFO.into())
|
.with_default_directive(LevelFilter::INFO.into())
|
||||||
.from_env_lossy();
|
.from_env_lossy();
|
||||||
|
|
||||||
let result = match (output_format, compact) {
|
let (layer1, layer2, layer3) = match (output_format, compact) {
|
||||||
(OutputFormat::Human, false) => tracing_subscriber::registry()
|
(OutputFormat::Human, false) => {
|
||||||
.with(env_filter)
|
(Some(tracing_subscriber::fmt::layer()), None, None)
|
||||||
.with(tracing_subscriber::fmt::layer())
|
}
|
||||||
.try_init(),
|
(OutputFormat::Human, true) => (
|
||||||
(OutputFormat::Human, true) => tracing_subscriber::registry()
|
None,
|
||||||
.with(env_filter)
|
Some(
|
||||||
.with(
|
|
||||||
tracing_subscriber::fmt::layer()
|
tracing_subscriber::fmt::layer()
|
||||||
.without_time()
|
.without_time()
|
||||||
.with_target(false),
|
.with_target(false),
|
||||||
)
|
),
|
||||||
.try_init(),
|
None,
|
||||||
(OutputFormat::Json, _) => tracing_subscriber::registry()
|
),
|
||||||
.with(env_filter)
|
(OutputFormat::Json, _) => {
|
||||||
.with(ECSLayerBuilder::default().stdout())
|
(None, None, Some(ECSLayerBuilder::default().stdout()))
|
||||||
.try_init(),
|
}
|
||||||
};
|
};
|
||||||
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ serde_json = { workspace = true }
|
|||||||
[features]
|
[features]
|
||||||
decimal = ["dep:rust_decimal", "utoipa?/decimal"]
|
decimal = ["dep:rust_decimal", "utoipa?/decimal"]
|
||||||
utoipa = ["dep:utoipa"]
|
utoipa = ["dep:utoipa"]
|
||||||
|
sentry = ["modrinth-log/sentry"]
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|||||||
Reference in New Issue
Block a user