feat: 集成 Sentry 错误监控 + 前端错误上报中继

后端:
  - Cargo.toml 添加 sentry/sentry-actix/sentry-tracing (v0.48)
  - main.rs 条件初始化 Sentry(SENTRY_DSN 环境变量控制)
  - 新增 handlers/sentry.rs — POST /api/sentry/events 前端中继端点
  - sentry-actix 全局中间件 + sentry-tracing 自动捕获 tracing::error!
  - .env.example 添加 SENTRY_DSN / SENTRY_TRACES_SAMPLE_RATE 配置

前端:
  - 新增 utils/sentryReporter.ts (批量上报/防抖/开发环境跳过)
  - app.ts 全局错误处理接入 Sentry (wx.onError/onUnhandledRejection/onPageNotFound)

Sentry 未配置 DSN 时完全无操作,无性能开销
This commit is contained in:
moira
2026-05-13 17:20:31 +08:00
committed by milky0217
parent 27c8979690
commit f8a95c7417
5 changed files with 945 additions and 61 deletions

925
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -10,6 +10,9 @@ dotenvy = "0.15.7"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
tracing-appender = "0.2"
sentry = "0.48"
sentry-actix = "0.48"
sentry-tracing = "0.48"
include_dir = "0.7.4"
jsonwebtoken = "9.3.1"
openssl = "0.10.73"

View File

@@ -5,6 +5,7 @@ pub mod favorites;
pub mod health;
pub mod meta;
pub mod payment;
pub mod sentry;
pub mod static_files;
pub mod user;
pub mod weather;
@@ -47,3 +48,4 @@ pub use payment::payment_index;
pub use payment::payment_login_status;
pub use payment::payment_page;
pub use payment::payment_success;
pub use sentry::report_frontend_error;

45
src/handlers/sentry.rs Normal file
View File

@@ -0,0 +1,45 @@
use actix_web::{web, HttpResponse, post};
use serde::Deserialize;
#[derive(Debug, Deserialize)]
pub struct SentryEventRequest {
pub level: Option<String>,
pub message: String,
pub stack: Option<String>,
pub page: Option<String>,
pub user_id: Option<i32>,
pub extra: Option<serde_json::Value>,
}
#[post("/api/sentry/events")]
pub async fn report_frontend_error(body: web::Json<SentryEventRequest>) -> HttpResponse {
let body = body.into_inner();
tracing::info!("前端错误上报: {}", body.message);
sentry::with_scope(
|scope| {
scope.set_tag("source", "wechat-miniprogram");
if let Some(page) = &body.page {
scope.set_tag("page", page);
}
if let Some(user_id) = body.user_id {
scope.set_user(Some(sentry::User {
id: Some(user_id.to_string()),
..Default::default()
}));
}
if let Some(extra) = &body.extra {
if let Some(obj) = extra.as_object() {
for (k, v) in obj {
scope.set_extra(k, sentry::protocol::Value::from(v.clone()));
}
}
}
},
|| {
sentry::capture_message(&body.message, sentry::Level::Error);
},
);
HttpResponse::Ok().json(serde_json::json!({"success": true}))
}

View File

@@ -22,7 +22,7 @@ use handlers::{
create_order, delete_weather, generate_code, generate_temp_token_handler, get_current_user_profile,
get_favorites, get_user_quota, get_weather_brief, get_weather_details,
get_user_orders, health_check, login, mock_login, mock_confirm, sync_order, payment_index, payment_login_status, payment_page, payment_success,
post_weather_data,
post_weather_data, report_frontend_error,
refresh_token, remove_favorite, root, save_user_profile, serve_static_files,
web_generate_login_code, web_login_confirm, web_login_auto_confirm,
};
@@ -64,13 +64,15 @@ fn create_server_config(
.app_data(web::Data::new(app_state))
// 安全响应头(全局中间件)
.wrap(
actix_web::middleware::DefaultHeaders::new()
DefaultHeaders::new()
.add(("X-Content-Type-Options", "nosniff"))
.add(("X-Frame-Options", "DENY"))
.add(("Content-Security-Policy", "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; img-src 'self' data:; font-src 'self' https://cdn.jsdelivr.net; connect-src 'self'"))
.add(("Referrer-Policy", "no-referrer-when-downgrade"))
.add(("Permissions-Policy", "geolocation=(), microphone=(), camera=()"))
)
// Sentry 请求监控(在 DSN 未配置时无操作)
.wrap(sentry_actix::Sentry::new())
// 根路径(无需认证)
.service(root) // #[get("/")] - 返回服务信息
// 支付页面(无需认证,外部浏览器访问)
@@ -92,6 +94,7 @@ fn create_server_config(
.service(web_generate_login_code)
.service(web_login_confirm)
.service(web_login_auto_confirm)
.service(report_frontend_error) // POST /api/sentry/events
.service(
web::scope("")
.wrap(from_fn(jwt_middleware))
@@ -137,6 +140,30 @@ async fn main() -> std::io::Result<()> {
std::env::set_var("FREE_USER_DATA_LIMIT", app_config.free_user_data_limit.to_string());
}
// Sentry 初始化(仅在 SENTRY_DSN 环境变量已设置时启用)
let _sentry_guard = if let Ok(dsn) = std::env::var("SENTRY_DSN") {
if !dsn.is_empty() {
info!("Sentry 已初始化");
Some(sentry::init((
dsn,
sentry::ClientOptions {
release: sentry::release_name!(),
traces_sample_rate: std::env::var("SENTRY_TRACES_SAMPLE_RATE")
.ok()
.and_then(|v| v.parse::<f32>().ok())
.unwrap_or(0.2),
..Default::default()
},
)))
} else {
info!("SENTRY_DSN 为空,跳过 Sentry 初始化");
None
}
} else {
info!("未设置 SENTRY_DSN跳过 Sentry 初始化");
None
};
// 初始化文件日志JSON 格式,带轮转)
let log_dir = std::path::Path::new("./logs");
std::fs::create_dir_all(log_dir).ok(); // 确保日志目录存在