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
2332use regex:: Regex ;
24- use sentry:: protocol:: { Event , Log , Value } ;
33+ use sentry:: protocol:: { Event , Log , User , Value } ;
34+ use std:: path:: Path ;
2535use std:: sync:: { Arc , LazyLock } ;
2636
2737/// Sentry 项目的 DSN(公开 client key,设计上即随客户端分发)。
@@ -33,6 +43,9 @@ pub const DEFAULT_DSN: &str =
3343/// 配置中控制是否开启错误上报的键名(以 `Enabled` 结尾 → 默认 `false`)。
3444pub const REPORTING_KEY : & str = "errorReportingEnabled" ;
3545
46+ /// 持久化匿名设备 ID 的文件名(与 `config.yaml` 同目录,相对 CWD)。
47+ pub const DEVICE_ID_FILE : & str = "device_id" ;
48+
3649/// 解析最终使用的 DSN(环境变量优先)。
3750fn 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/// 覆盖前端与后端的全部事件(前端事件经插件转发后也走这里)。
94138fn 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 这条管道。
131182fn 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