Skip to content

Commit c0cfa5b

Browse files
committed
fix(sentry): 用匿名 device_id 算 DAU,顺手堵 hostname 漏洞
之前两个问题: 1. scrub_log 没清 Logs attributes 里的 server.address,hostname (Windows 自定义机名,常是用户姓名/昵称)从这条管道全量上报, 与文件头承诺的"丢弃 server_name"行为打架。 2. errors / logs 都没有稳定 user.id,count_unique(user) 在 Sentry 侧永远是 0,算不出 DAU。 改动: - 启动时持久化生成 UUIDv4 写入 ./device_id,set 到 scope.user.id。 scope 上的 user 会自动随 errors / logs 一起上报,DAU = count_unique(user)。 - scrub_event 不再清空整个 user,只保留 id,其余字段(email / username / ip)继续抹掉,行为更精确。 - scrub_log 增加 attributes.remove("server.address"),关上 hostname 漏点。 - 文件头隐私章节同步更新。 Cargo.toml 新增 uuid 直接依赖(之前是其他 crate 的传递依赖)。
1 parent 55eb48b commit c0cfa5b

2 files changed

Lines changed: 109 additions & 7 deletions

File tree

lol-record-analysis-tauri/src-tauri/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,3 +68,5 @@ tauri-plugin-mcp-bridge = "0.11"
6868
# log + logs feature:把 `log` 记录转成 Sentry Structured Logs(见 observability.rs / main.rs)
6969
sentry = { version = "0.42", features = ["log", "logs"] }
7070
tauri-plugin-sentry = "0.5"
71+
# 匿名设备 ID(observability.rs 持久化生成,仅用于 Sentry DAU 去重)
72+
uuid = { version = "1", features = ["v4"] }

lol-record-analysis-tauri/src-tauri/src/observability.rs

Lines changed: 107 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,20 +8,30 @@
88
//! ## 隐私
99
//!
1010
//! - 默认 `send_default_pii: false`,不附带 IP / Cookie。
11-
//! - [`scrub_event`] 丢弃 `user` / `server_name`,并对消息、**异常正文**、面包屑、extra
11+
//! - [`scrub_event`] 把 `user` 收敛到只保留匿名 `id`(见 [`device_id`]),丢弃 `server_name`
12+
//! (Windows 上常是用户自定义机名 / 昵称),并对消息、**异常正文**、面包屑、extra
1213
//! (含**嵌套数组 / 对象**)中的字符串做 [`redact_pii`]:覆盖 query / JSON / Debug 三种
1314
//! 形态的 puuid / 召唤师名 / UUID。
15+
//! - [`scrub_log`] 移除 Logs attribute 里的 `server.address`(hostname 从这条管道漏过,
16+
//! `scrub_event` 不覆盖),并对 `body` 跑 [`redact_pii`]。
1417
//! - 局限:自由文本里无字段名上下文直接拼接的名字仍可能漏网——根本防线是默认关闭 +
1518
//! 不在日志里拼接玩家名。
1619
//!
20+
//! ## 匿名设备 ID
21+
//!
22+
//! 每台机器首次启用上报时生成一个 UUIDv4,写入 [`DEVICE_ID_FILE`],后续启动复用。
23+
//! 通过 `scope.user.id` 注入,errors / logs 两条管道都能据此用 `count_unique(user)`
24+
//! 算 DAU,无需上报 IP / hostname。
25+
//!
1726
//! ## 开关
1827
//!
1928
//! - **debug 构建**:默认开启,方便开发期验证。
2029
//! - **release 构建**:默认关闭,用户需在「设置 → 常规」中开启 `errorReportingEnabled`
2130
//! (opt-in),重启后生效。
2231
2332
use regex::Regex;
24-
use sentry::protocol::{Event, Log, Value};
33+
use sentry::protocol::{Event, Log, User, Value};
34+
use std::path::Path;
2535
use std::sync::{Arc, LazyLock};
2636

2737
/// Sentry 项目的 DSN(公开 client key,设计上即随客户端分发)。
@@ -33,6 +43,9 @@ pub const DEFAULT_DSN: &str =
3343
/// 配置中控制是否开启错误上报的键名(以 `Enabled` 结尾 → 默认 `false`)。
3444
pub const REPORTING_KEY: &str = "errorReportingEnabled";
3545

46+
/// 持久化匿名设备 ID 的文件名(与 `config.yaml` 同目录,相对 CWD)。
47+
pub const DEVICE_ID_FILE: &str = "device_id";
48+
3649
/// 解析最终使用的 DSN(环境变量优先)。
3750
fn dsn() -> String {
3851
option_env!("SENTRY_DSN").unwrap_or(DEFAULT_DSN).to_string()
@@ -64,6 +77,7 @@ pub fn init() -> Option<sentry::ClientInitGuard> {
6477
return None;
6578
}
6679

80+
let device_id = device_id(Path::new(DEVICE_ID_FILE));
6781
let guard = sentry::init((
6882
dsn(),
6983
sentry::ClientOptions {
@@ -84,17 +98,53 @@ pub fn init() -> Option<sentry::ClientInitGuard> {
8498
..Default::default()
8599
},
86100
));
101+
// 设置匿名设备 ID 到全局 scope —— errors 与 logs 两条管道都会读 scope 上的 user,
102+
// 让 `count_unique(user)` 在 Sentry 侧能算出 DAU。
103+
sentry::configure_scope(|scope| {
104+
scope.set_user(Some(User {
105+
id: Some(device_id),
106+
..Default::default()
107+
}));
108+
});
87109
log::info!("Sentry error reporting ENABLED");
88110
Some(guard)
89111
}
90112

113+
/// 读取或首次生成匿名设备 ID。
114+
///
115+
/// 失败时返回随机 UUID 但**不写盘**——意味着这次会话被当作新设备,
116+
/// 不会让无法持久化的环境(例如只读 CWD)让上报整体崩。
117+
fn device_id(path: &Path) -> String {
118+
if let Ok(existing) = std::fs::read_to_string(path) {
119+
let trimmed = existing.trim();
120+
if !trimmed.is_empty() {
121+
return trimmed.to_string();
122+
}
123+
}
124+
let new_id = uuid::Uuid::new_v4().to_string();
125+
if let Err(e) = std::fs::write(path, &new_id) {
126+
log::warn!(
127+
"failed to persist device_id to {}: {} (will regenerate next launch)",
128+
path.display(),
129+
e
130+
);
131+
}
132+
new_id
133+
}
134+
91135
/// `before_send` 钩子:在事件发送前移除 / 脱敏 PII。
92136
///
93137
/// 覆盖前端与后端的全部事件(前端事件经插件转发后也走这里)。
94138
fn scrub_event(mut event: Event<'static>) -> Option<Event<'static>> {
95-
// 主机名常包含用户真实姓名(如 "Zhang-MacBook");用户对象可能含 id / ip
139+
// 主机名常包含用户真实姓名(如 "Zhang-MacBook")。
96140
event.server_name = None;
97-
event.user = None;
141+
// 保留 `user.id`(init() 注入的匿名 UUID,用于 DAU 去重),清掉其余可能 PII 的字段。
142+
if let Some(user) = event.user.take() {
143+
event.user = Some(User {
144+
id: user.id,
145+
..Default::default()
146+
});
147+
}
98148

99149
if let Some(message) = event.message.take() {
100150
event.message = Some(redact_pii(&message));
@@ -120,16 +170,18 @@ fn scrub_event(mut event: Event<'static>) -> Option<Event<'static>> {
120170
Some(event)
121171
}
122172

123-
/// `before_send_log` 钩子:在结构化日志发送前脱敏正文。
173+
/// `before_send_log` 钩子:在结构化日志发送前脱敏正文 + 移除 hostname
124174
///
125175
/// 全量转发(含 info)下,日志正文 `body` 是 PII 的主要载体——LCU 命令行里的
126176
/// `*-auth-token`、`config.yaml` 转储、puuid / 召唤师名都在这里。对 `body` 跑
127177
/// [`redact_pii`](已覆盖 token / 名字 / UUID / 长 token)。
128178
///
129-
/// 注:sentry-log 附加的 attributes 是模块路径 / 文件 / 行号等元数据,不含玩家 PII,
130-
/// 故只洗 `body`。
179+
/// 此外 Sentry Logs 会自动附 `server.address` attribute(hostname),Windows 上
180+
/// 常是用户自定义机名 / 昵称("YXL"、"三火"、"saber"…),属 PII,必须删除。
181+
/// errors 上的 `event.server_name` 由 [`scrub_event`] 负责,这里只管 logs 这条管道。
131182
fn scrub_log(mut log: Log) -> Option<Log> {
132183
log.body = redact_pii(&log.body);
184+
log.attributes.remove("server.address");
133185
Some(log)
134186
}
135187

@@ -328,4 +380,52 @@ mod tests {
328380
"异常正文里的 uuid 应被脱敏: {val}"
329381
);
330382
}
383+
384+
#[test]
385+
fn should_keep_user_id_but_strip_other_user_fields() {
386+
let mut event = Event::default();
387+
event.user = Some(User {
388+
id: Some("abc-device-uuid".to_string()),
389+
email: Some("real@example.com".to_string()),
390+
username: Some("realname".to_string()),
391+
..Default::default()
392+
});
393+
let scrubbed = scrub_event(event).expect("event kept");
394+
let user = scrubbed.user.expect("user kept");
395+
assert_eq!(
396+
user.id.as_deref(),
397+
Some("abc-device-uuid"),
398+
"id 应保留用于 DAU 去重"
399+
);
400+
assert!(user.email.is_none(), "email 不应留下: {:?}", user.email);
401+
assert!(
402+
user.username.is_none(),
403+
"username 不应留下: {:?}",
404+
user.username
405+
);
406+
}
407+
408+
// 没给 `should_strip_server_address_from_log_attributes` 写单测:sentry 0.42 的
409+
// `Log` struct 字段不稳定(severity_number / span_id 等版本间会变),构造 fixture
410+
// 反而比被测代码本身脆。`scrub_log` 的改动是 `attributes.remove("server.address")` 一行,
411+
// 验证通过部署后查 Sentry:30d 内应看到 `count_unique(server.address)` 停止增长。
412+
413+
#[test]
414+
fn should_generate_and_persist_device_id() {
415+
let tmp = std::env::temp_dir().join(format!(
416+
"rank-analysis-device-id-test-{}",
417+
uuid::Uuid::new_v4()
418+
));
419+
let _ = std::fs::remove_file(&tmp);
420+
421+
let first = device_id(&tmp);
422+
assert!(!first.is_empty(), "首次应生成非空 id");
423+
let on_disk = std::fs::read_to_string(&tmp).expect("写盘");
424+
assert_eq!(on_disk.trim(), first, "首次应落盘");
425+
426+
let second = device_id(&tmp);
427+
assert_eq!(first, second, "再次调用应复用,而不是重新生成");
428+
429+
let _ = std::fs::remove_file(&tmp);
430+
}
331431
}

0 commit comments

Comments
 (0)