Skip to main content

futu_core/
log.rs

1use std::path::Path;
2use std::sync::OnceLock;
3
4use tracing_subscriber::{
5    EnvFilter, Layer, filter::filter_fn, fmt, layer::SubscriberExt, reload, util::SubscriberInitExt,
6};
7
8/// 全局人类可读日志时间戳:使用系统本地时区并输出 RFC3339 offset。
9///
10/// 默认 tracing formatter 使用 UTC (`Z`);本项目排障通常发生在 Asia/Shanghai /
11/// Asia/Hong_Kong 时区,UTC 时间会让用户对照真机事件时多一层换算。这里不写死
12/// `+08:00`,而是读取 OS local timezone;部署到其他地区时仍按当地日志习惯输出。
13#[derive(Clone, Copy, Debug)]
14pub struct LocalRfc3339Timer;
15
16impl fmt::time::FormatTime for LocalRfc3339Timer {
17    fn format_time(&self, w: &mut fmt::format::Writer<'_>) -> std::fmt::Result {
18        write!(
19            w,
20            "{}",
21            chrono::Local::now().to_rfc3339_opts(chrono::SecondsFormat::Micros, true)
22        )
23    }
24}
25
26/// **v1.4.106 codex 1110 F7 [P2]** — 全局 EnvFilter reload handle,
27/// 支持运行时通过 `RemoteCmd set_log_level` 修改 tracing filter。
28///
29/// 在 `init_logging_with_level` / `init_json_logging_with_level` /
30/// `init_logging_with_audit` 三个入口里 install reload layer 时写入;
31/// `set_runtime_log_level` 在外部业务 handler 里 reload。
32///
33/// **唯一性约束**:tracing global subscriber 只能 init 一次,所以同进程仅一份
34/// reload handle。重复 init 会被 `tracing_subscriber::registry().try_init()`
35/// 失败,此 handle 也只 set 一次(`OnceLock`)。
36static GLOBAL_RELOAD_HANDLE: OnceLock<reload::Handle<EnvFilter, tracing_subscriber::Registry>> =
37    OnceLock::new();
38
39/// 解析 RemoteCmd 风格的 level 字符串("no" / "debug" / "info" / ...)到
40/// EnvFilter 字符串("off" / "debug" / "info" / ...)。
41///
42/// 对齐 C++ RemoteCmd `set_log_level` 支持的 6 个 level 值。
43fn map_remote_level_to_env_filter(level: &str) -> Option<&'static str> {
44    match level.to_lowercase().as_str() {
45        "no" => Some("off"),
46        "debug" => Some("debug"),
47        "info" => Some("info"),
48        "warning" => Some("warn"),
49        "error" => Some("error"),
50        "fatal" => Some("error"), // tracing 没 fatal,等价 error
51        _ => None,
52    }
53}
54
55/// **v1.4.106 codex 1110 F7 [P2] Stable API** — 运行时修改 tracing filter level。
56///
57/// - 接受 RemoteCmd 风格的 level 值("no" / "debug" / "info" / "warning" /
58///   "error" / "fatal"),内部映射到 `EnvFilter` 接受的等价值。
59/// - 修改成功 → 返 `Ok(applied_filter_string)`,调用方可作 verify 提示。
60/// - 修改失败 → 返 `Err(reason)`:
61///   - "level invalid": level 字符串不在合法集合
62///   - "reload handle not initialized": init_logging 没用 reload-aware 入口
63///     (或 .init() 失败)
64///   - "reload failed: <e>": tracing_subscriber 内部 reload 错(一般极罕见)
65///
66/// 调用此函数后,所有现有 `tracing::*!` 宏的过滤器会立刻按新 filter 工作,
67/// 不需要重启进程。这是真"effect",配 `RemoteCmd set_log_level` 修复
68/// silent-success 反模式。
69pub fn set_runtime_log_level(level: &str) -> Result<String, String> {
70    let mapped = match map_remote_level_to_env_filter(level) {
71        Some(s) => s,
72        None => return Err("level invalid".to_string()),
73    };
74
75    let handle = match GLOBAL_RELOAD_HANDLE.get() {
76        Some(h) => h,
77        None => return Err("reload handle not initialized".to_string()),
78    };
79
80    let new_filter = EnvFilter::try_new(mapped)
81        .map_err(|e| format!("EnvFilter parse failed for '{mapped}': {e}"))?;
82
83    handle
84        .reload(new_filter)
85        .map_err(|e| format!("reload failed: {e}"))?;
86
87    Ok(mapped.to_string())
88}
89
90/// **v1.4.106 codex 1110 F7 [P2]** — query 当前 reload handle 是否已 install。
91/// handler 用来给用户 helpful error message。
92pub fn is_runtime_reload_available() -> bool {
93    GLOBAL_RELOAD_HANDLE.get().is_some()
94}
95
96/// **Stable API** — 初始化日志系统(stderr fmt layer)。
97///
98/// 优先级: RUST_LOG 环境变量 > level 参数 > 默认 info
99///
100/// v1.4.106 codex 1110 F7 [P2]:install runtime reload handle(用于
101/// `RemoteCmd set_log_level` 真切 filter)。
102pub fn init_logging_with_level(level: &str) {
103    let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(level));
104    let (filter_layer, reload_handle) = reload::Layer::new(filter);
105
106    let fmt_layer = fmt::layer()
107        .with_timer(LocalRfc3339Timer)
108        .with_target(true)
109        .with_thread_ids(true)
110        .with_file(true)
111        .with_line_number(true)
112        .with_writer(std::io::stderr);
113
114    let registry = tracing_subscriber::registry()
115        .with(filter_layer)
116        .with(fmt_layer);
117    if registry.try_init().is_ok() {
118        // 仅 init 成功时 set 全局 handle(重复 init 不覆盖)。
119        let _ = GLOBAL_RELOAD_HANDLE.set(reload_handle);
120    }
121}
122
123/// **Stable API** — 初始化日志系统(默认 info 级别)。examples / 测试程序
124/// 常用入口。
125pub fn init_logging() {
126    init_logging_with_level("info");
127}
128
129/// **Stable API** — 初始化 JSON 格式日志(生产环境 / 日志聚合用)。
130///
131/// v1.4.106 codex 1110 F7 [P2]:install runtime reload handle。
132pub fn init_json_logging_with_level(level: &str) {
133    let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(level));
134    let (filter_layer, reload_handle) = reload::Layer::new(filter);
135
136    let fmt_layer = fmt::layer()
137        .json()
138        .with_timer(LocalRfc3339Timer)
139        .with_target(true)
140        .with_thread_ids(true);
141
142    let registry = tracing_subscriber::registry()
143        .with(filter_layer)
144        .with(fmt_layer);
145    if registry.try_init().is_ok() {
146        let _ = GLOBAL_RELOAD_HANDLE.set(reload_handle);
147    }
148}
149
150/// **Stable API** — 初始化 JSON 格式日志(默认 info 级别)。
151pub fn init_json_logging() {
152    init_json_logging_with_level("info");
153}
154
155/// **Stable API** — 初始化日志 + 可选的 audit JSONL 文件。
156///
157/// - 常规事件走 stderr(与 [`init_logging_with_level`] 一致)
158/// - 如果 `audit_path` 传了,额外加一个 JSON 层,只捕获 `target = "futu_audit"` 的
159///   事件并写到文件 / 目录。返回的 `WorkerGuard` 必须保留到进程退出,否则
160///   tracing-appender 的后台线程可能丢事件。
161/// - 传 `None` 时返回 `Ok(None)`,等价于 `init_logging_with_level`。
162pub fn init_logging_with_audit(
163    level: &str,
164    audit_path: Option<&Path>,
165) -> std::io::Result<Option<tracing_appender::non_blocking::WorkerGuard>> {
166    let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(level));
167    // v1.4.106 codex 1110 F7 [P2]: install reload handle for runtime set_log_level.
168    let (filter_layer, reload_handle) = reload::Layer::new(filter);
169
170    let fmt_layer = fmt::layer()
171        .with_timer(LocalRfc3339Timer)
172        .with_target(true)
173        .with_thread_ids(true)
174        .with_file(true)
175        .with_line_number(true)
176        .with_writer(std::io::stderr);
177
178    let registry = tracing_subscriber::registry()
179        .with(filter_layer)
180        .with(fmt_layer);
181
182    let init_ok = if let Some(path) = audit_path {
183        // 用 futu-auth 的 open_writer(单文件 or 每日滚动目录)
184        let (writer, guard) = open_audit_writer(path)?;
185        let audit_layer = fmt::layer()
186            .json()
187            .flatten_event(true)
188            .with_current_span(false)
189            .with_span_list(false)
190            .with_target(true)
191            .with_writer(writer)
192            .with_filter(filter_fn(|meta| meta.target() == "futu_audit"));
193        let init_ok = registry.with(audit_layer).try_init().is_ok();
194        if init_ok {
195            let _ = GLOBAL_RELOAD_HANDLE.set(reload_handle);
196        }
197        return Ok(if init_ok { Some(guard) } else { None });
198    } else {
199        let ok = registry.try_init().is_ok();
200        if ok {
201            let _ = GLOBAL_RELOAD_HANDLE.set(reload_handle);
202        }
203        ok
204    };
205    let _ = init_ok;
206    Ok(None)
207}
208
209/// 打开 audit 日志 writer。为了避免 futu-core 反向依赖 futu-auth,
210/// 这里复制了 `futu_auth::audit::open_writer` 的启发式逻辑。两者行为必须一致,
211/// 任一改动都要同步对方。
212///
213/// v1.4.87 CLAUDE.md 坑 #49: 目录 0700 / 文件 0600 + /tmp/ world-readable warn.
214/// futu-core 不依赖 futu-auth, 所以 helper 在本文件 duplicate.
215fn open_audit_writer(
216    path: &Path,
217) -> std::io::Result<(
218    tracing_appender::non_blocking::NonBlocking,
219    tracing_appender::non_blocking::WorkerGuard,
220)> {
221    let is_dir_hint = path.is_dir()
222        || path.as_os_str().to_string_lossy().ends_with('/')
223        || path.extension().is_none();
224
225    // v1.4.87: /tmp world-readable warn
226    warn_if_world_readable_path(path);
227
228    if is_dir_hint {
229        std::fs::create_dir_all(path)?;
230        tighten_dir_perms(path); // v1.4.87: 0700
231        // v1.4.93 BUG-5318-001 (跨 4 版半修补完): rolling::daily 创建文件不带
232        // mode → 默认 umask-dependent (典型 0644). 见 futu-auth audit.rs 同款修法.
233        tighten_log_files_in_dir(path);
234        let appender = tracing_appender::rolling::daily(path, "futu-audit.log");
235        let wrapped = Mode0600Appender::new(appender, path.to_path_buf());
236        Ok(tracing_appender::non_blocking(wrapped))
237    } else {
238        if let Some(parent) = path.parent()
239            && !parent.as_os_str().is_empty()
240        {
241            std::fs::create_dir_all(parent)?;
242            tighten_dir_perms(parent); // v1.4.87: 0700
243        }
244        let file = open_file_0600(path)?; // v1.4.87: 0600
245        Ok(tracing_appender::non_blocking(file))
246    }
247}
248
249/// v1.4.87 CLAUDE.md 坑 #49: 同 `futu_auth::audit::tighten_dir_perms`.
250/// 必须和 futu-auth 版本同步, 当前 duplicate 避免循环依赖.
251fn tighten_dir_perms(path: &Path) {
252    #[cfg(unix)]
253    {
254        use std::os::unix::fs::PermissionsExt;
255        if let Ok(meta) = std::fs::metadata(path) {
256            let cur = meta.permissions().mode() & 0o777;
257            if cur & 0o077 != 0 {
258                let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700));
259            }
260        }
261    }
262    #[cfg(not(unix))]
263    {
264        let _ = path;
265    }
266}
267
268/// v1.4.87 CLAUDE.md 坑 #49: 同 `futu_auth::audit::open_file_0600`.
269fn open_file_0600(path: &Path) -> std::io::Result<std::fs::File> {
270    let mut opts = std::fs::OpenOptions::new();
271    opts.append(true).create(true);
272    #[cfg(unix)]
273    {
274        use std::os::unix::fs::OpenOptionsExt;
275        opts.mode(0o600);
276    }
277    let file = opts.open(path)?;
278    #[cfg(unix)]
279    {
280        use std::os::unix::fs::PermissionsExt;
281        if let Ok(meta) = file.metadata() {
282            let cur = meta.permissions().mode() & 0o777;
283            if cur & 0o077 != 0 {
284                let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
285            }
286        }
287    }
288    Ok(file)
289}
290
291/// v1.4.87 CLAUDE.md 坑 #49: 同 `futu_auth::audit::warn_if_world_readable_path`.
292fn warn_if_world_readable_path(path: &Path) {
293    let s = path.as_os_str().to_string_lossy();
294    let world_readable_prefixes = ["/tmp/", "/var/tmp/", "/private/tmp/", "/tmp"];
295    for prefix in &world_readable_prefixes {
296        if s == *prefix || s.starts_with(prefix) {
297            eprintln!(
298                "⚠️  audit log path {s:?} 位于 world-readable 目录 (mode 1777). \
299                 audit log 含账户 id + redacted token 占位 + timestamp, 同机其他\n\
300                 用户 (包括 agent skill) 可读. 建议改用 ~/.futu-opend-rs/logs/ \
301                 或 /var/log/futu/ 等 0700 目录."
302            );
303            break;
304        }
305    }
306}
307
308/// v1.4.93 BUG-5318-001: 同 `futu_auth::audit::Mode0600Appender`. 必须和
309/// futu-auth 版本同步, 当前 duplicate 避免循环依赖.
310struct Mode0600Appender {
311    inner: tracing_appender::rolling::RollingFileAppender,
312    dir: std::path::PathBuf,
313    last_tighten: std::sync::atomic::AtomicU64,
314}
315
316impl Mode0600Appender {
317    fn new(inner: tracing_appender::rolling::RollingFileAppender, dir: std::path::PathBuf) -> Self {
318        Self {
319            inner,
320            dir,
321            last_tighten: std::sync::atomic::AtomicU64::new(0),
322        }
323    }
324
325    fn maybe_tighten(&self) {
326        let now = std::time::SystemTime::now()
327            .duration_since(std::time::UNIX_EPOCH)
328            .map(|d| d.as_secs())
329            .unwrap_or(0);
330        let last = self.last_tighten.load(std::sync::atomic::Ordering::Relaxed);
331        if now > last
332            && self
333                .last_tighten
334                .compare_exchange(
335                    last,
336                    now,
337                    std::sync::atomic::Ordering::Relaxed,
338                    std::sync::atomic::Ordering::Relaxed,
339                )
340                .is_ok()
341        {
342            tighten_log_files_in_dir(&self.dir);
343        }
344    }
345}
346
347impl std::io::Write for Mode0600Appender {
348    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
349        let n = self.inner.write(buf)?;
350        self.maybe_tighten();
351        Ok(n)
352    }
353
354    fn flush(&mut self) -> std::io::Result<()> {
355        self.inner.flush()
356    }
357}
358
359/// v1.4.93 BUG-5318-001: 同 `futu_auth::audit::tighten_log_files_in_dir`.
360fn tighten_log_files_in_dir(dir: &Path) {
361    #[cfg(unix)]
362    {
363        use std::os::unix::fs::PermissionsExt;
364        let Ok(rd) = std::fs::read_dir(dir) else {
365            return;
366        };
367        for entry in rd.flatten() {
368            let p = entry.path();
369            let name = match p.file_name().and_then(|n| n.to_str()) {
370                Some(n) => n,
371                None => continue,
372            };
373            if !name.starts_with("futu-audit.log") {
374                continue;
375            }
376            if let Ok(meta) = std::fs::metadata(&p) {
377                if !meta.is_file() {
378                    continue;
379                }
380                let cur = meta.permissions().mode() & 0o777;
381                if cur & 0o077 != 0 {
382                    let _ = std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o600));
383                }
384            }
385        }
386    }
387    #[cfg(not(unix))]
388    {
389        let _ = dir;
390    }
391}