Rust in Everything
基于 tracing 0.1.44

每条日志打印两次

主线位置:事件主线的两条旁路接成了一个环。info! 走完正常的事件主线、被 fmt 输出一次;log-always 让宏旁边的 __tracing_log! 再发一条 log 记录,LogTracer 把它变回 tracing 事件,直接交给 Dispatch::event,接上主线的最后一段,于是 fmt 再输出一次。

flowchart TB
  m["info! 宏"] --> ed["Event::dispatch"]
  ed --> f1["fmt 输出第一次"]
  m --> tl["__tracing_log!"]
  tl --> la{"log-always"}
  la -->|开| lg["log::logger().log"]
  lg --> lt["LogTracer"]
  lt --> de["Dispatch::event"]
  de --> f2["fmt 输出第二次"]
  la -->|"没开,已设 dispatcher"| skip["不发 log 记录"]

tracing 和 log 两个方向的转发怎么工作、各种组合输出什么,在 和 log 互通 里逐一跑过。结论是:tracing 这边只开 log feature 不会重复,只有 log-always 加上 LogTracer 才会。这一页讲这种组合在真实项目里是怎么出现的(你自己并没有开 log-always),以及另外两种看起来像「重复」的情况。

复现:一个依赖开了 log-always

程序本身只依赖普通的 tracing,另有一个本地库 noisylib 在自己的 Cargo.toml 里开了 log-always:

# noisylib/Cargo.toml
[dependencies]
tracing = { version = "=0.1.44", features = ["log-always"] }
// noisylib/src/lib.rs
pub fn work() {
  tracing::info!("from noisylib");
}
# 程序的 Cargo.toml
[dependencies]
tracing = "=0.1.44"
tracing-subscriber = "=0.3.23"
noisylib = { path = "noisylib" }
fn main() {
  let mode = std::env::args().nth(1).unwrap_or_default();
  let builder = tracing_subscriber::fmt().with_ansi(false).without_time();
  match mode.as_str() {
    // 默认:fmt().init() 顺手装上 LogTracer
    "init" => builder.init(),
    // 只设全局 subscriber,不装 LogTracer
    "set-global" => tracing::subscriber::set_global_default(builder.finish()).unwrap(),
    _ => unreachable!(),
  }
  tracing::info!("from app");
  noisylib::work();
}

M2 Max、rustc 1.99.0、--release,两种模式各跑 3 次输出相同。init:

 INFO dup: from app
 INFO dup: from app
 INFO noisylib: from noisylib
 INFO noisylib: from noisylib

set-global:

 INFO dup: from app
 INFO noisylib: from noisylib

两件事:log-always 是 noisylib 开的,程序自己的 from app 也重复了;两行一模一样,第二行的 target 也是 dup 而不是 log,因为 fmt 用 NormalizeEvent 把 LogTracer 塞进字段里的原始 target 还原了出来(和 log 互通 讲过)。光看输出分不出哪一行是绕回来的。

为什么

两半各来自一处。

tracing 这半: 开了 log-always 时,宏里用的 if_log_enabled! 是这个版本:

#[cfg(all(feature = "log", feature = "log-always"))]
#[doc(hidden)]
#[macro_export]
macro_rules! if_log_enabled {
    // ...
    ($lvl:expr, $if_log:block else $else_block:block) => {
        if $crate::level_to_log!($lvl) <= $crate::log::STATIC_MAX_LEVEL {
            #[allow(unused_braces)]
            $if_log
        } else {
            $else_block
        }
    };
}

只开 log 的 那个版本 在这里多一个 if !$crate::dispatcher::has_been_set(),装过 subscriber 就不再发 log 记录;log-always 版本没有这个判断,只看 log 的编译期上限。

tracing-subscriber 这半: SubscriberInitExt::try_init 在默认的 tracing-log feature 下,设完全局 subscriber 就调 LogTracer::builder()...init(),把 LogTracer 装成 log 的 logger。fmt().init()、registry().with(..).init() 都走这里。set-global 模式只做了前一半,没有 LogTracer,log 记录发给了 log 的默认空 logger,所以不重复。

📝NOTE
没有蠢问题

问: log-always 是 noisylib 开的,为什么我自己 crate 里的 info! 也重复了?

答: Cargo 对同一个版本的 tracing 只编译一份,所有依赖它的 crate 要的 feature 取并集(cargo tree 的输出里 tracing feature "log-always" 挂在 tracing v0.1.44 这一个节点下)。if_log_enabled! 是 tracing 里用 #[cfg(feature = "log-always")] 选出的 #[macro_export] 宏,你的 info! 展开时用的是 $crate::if_log_enabled!,也就是这份编译好的 tracing 里的版本。feature 是整个构建的,不是「谁开谁用」。

找出是谁开的

cargo tree -e features -i tracing

上面那个程序的输出(去掉了本地路径):

tracing v0.1.44
├── tracing feature "attributes"
│   └── tracing feature "default"
│       ├── noisylib v0.1.0 
│       │   └── noisylib feature "default"
│       │       └── pit3-la v0.1.0 
│       │           └── pit3-la feature "default" (command-line)
│       └── pit3-la v0.1.0  (*)
├── tracing feature "default" (*)
├── tracing feature "log"
│   └── tracing feature "log-always"
│       └── noisylib v0.1.0  (*)
├── tracing feature "log-always" (*)
├── tracing feature "std"
│   └── tracing feature "default" (*)
└── tracing feature "tracing-attributes"
    └── tracing feature "attributes" (*)

看 tracing feature "log-always" 下面挂的是谁。tracing 的 crate 文档对这个 feature 说得很直接:「libraries generally should not enable the "log-always" feature, as doing so will prevent applications from being able to opt out of the log records」(lib.rs)。

修法

按优先顺序:

  1. 去掉 log-always。 是你自己的 crate 开的就删掉,改成 log 或什么都不开;是第三方库开的,看它有没有把这个 feature 做成可选的(库自己的某个 feature 转发到 tracing/log-always),关掉它,或者给上游提 issue。

  2. 去不掉,就不装 LogTracer。 用 tracing::subscriber::set_global_default(fmt().finish())(上面的 set-global),或者给 tracing-subscriber 写 default-features = false 再列出需要的 feature、不含 tracing-log。代价是只用 log 的那些依赖的记录进不了 tracing,和 log 互通 的表里 fmt-global 那一行就是这个结果。

  3. 还要 LogTracer,就让它忽略这些 target。 自己用 LogTracer::builder().ignore_crate(..) 装,并且不再调会自动装 LogTracer 的 init()。麻烦在于 feature 合并后连你自己 crate 的事件也会绕回来,要忽略的不只是开 log-always 的那个库:

    fn main() {
      let sub = tracing_subscriber::fmt().with_ansi(false).without_time().finish();
      tracing::subscriber::set_global_default(sub).unwrap();
      tracing_log::LogTracer::builder()
        .ignore_crate("noisylib")
        .init()
        .unwrap();
      tracing::info!("from app");
      noisylib::work();
      log::info!("from log::info!");
    }
    

    (另加 tracing-log = "=0.2.0"、log = "0.4"。)跑 3 次输出相同:

     INFO dup_ignore2: from app
     INFO dup_ignore2: from app
     INFO noisylib: from noisylib
     INFO dup_ignore2: from log::info!
    

    同一个程序放进名为 dup_ignore 的 bin、再加一行 .ignore_crate("dup_ignore")(程序自己的 target),跑 3 次输出相同:

     INFO dup_ignore: from app
     INFO noisylib: from noisylib
    

    from app 只剩一行,可 from log::info! 也一起没了:ignore_crate 按 target 前缀忽略,分不清这条 log 记录是 tracing 绕回来的,还是你自己用 log::info! 写的。所以这只适合自己的 crate 不直接用 log 的情况。

另外两种「重复」

栈里有两个 fmt 层。 fmt() 本身已经带一个 fmt::Layer,再 .with(fmt::layer()) 就是两个,每个都把事件写一遍:

use tracing_subscriber::fmt;
use tracing_subscriber::prelude::*;

// 一个库在自己的初始化函数里装 subscriber
fn library_init() {
  let r = fmt().with_ansi(false).without_time().try_init();
  println!("library try_init: {r:?}");
}

fn main() {
  let mode = std::env::args().nth(1).unwrap();
  println!("== {mode}");
  match mode.as_str() {
    // fmt() 已经带一个 fmt::Layer,又叠了一个
    "fmt-plus-layer" => fmt()
      .with_ansi(false)
      .without_time()
      .finish()
      .with(fmt::layer().with_ansi(false).without_time())
      .init(),
    // 库先装,程序再装
    "library-init" => {
      library_init();
      let r = fmt().with_ansi(false).without_time().try_init();
      println!("app try_init: {r:?}");
    }
    _ => unreachable!(),
  }
  tracing::info!("hello");
}

(只需要 tracing 和 tracing-subscriber,tracing 不开任何 feature。)各跑 3 次输出相同:

== fmt-plus-layer
 INFO two_fmt: hello
 INFO two_fmt: hello
== library-init
library try_init: Ok(())
app try_init: Err(SetGlobalDefaultError("a global default trace dispatcher has already been set"))
 INFO two_fmt: hello

fmt-plus-layer 是真重复:Layered::event 先把事件交给内层(fmt() 里的那个 fmt::Layer),再调外层的 on_event(Layer 与 Layered)。两层写到同一个 stdout,内容又相同,看起来就是每行两遍。本意多半是「终端一份、文件一份」,那就从 registry() 起步、给两层各配 writer,见 文件要 DEBUG、终端要 INFO,结果两边一样。

library-init 不会重复:全局默认只能设一次,后来的 try_init 失败,一条事件只交给一个 dispatcher。它的问题是另一种:生效的是库的配置而不是你的,换成 init() 就是 panic,见 Unable to install global subscriber 与 failed to set global default subscriber。

怎么确认自己没踩

  • cargo tree -e features -i tracing | grep log-always 没有输出,log-always 这条路就排除了。
  • 有输出时,临时把 init() 换成 set_global_default(..finish()):重复消失,就是 log-always 加 LogTracer;还在,就数一下栈里有几个输出到同一处的 fmt 层。
  • 用 log-always 是有意的(log 的 logger 管文本日志,tracing 只做指标或分布式追踪),那就不要同时装 LogTracer,两边各收一份才是这个 feature 的设计用法。

小结

  • 唯一会让同一条事件被同一个 fmt 层输出两次的组合是 log-always 加 LogTracer;只开 log 不会,因为装过 subscriber 后 has_been_set() 为真,宏不再发 log 记录。
  • log-always 通常不是你开的:feature 在整个构建里合并,任何一个依赖开了,所有 crate 的 tracing 宏(包括你自己的)都会发 log 记录。cargo tree -e features -i tracing 找出是谁。
  • fmt().init() 默认装 LogTracer;去不掉 log-always 时,改用 set_global_default 或关掉 tracing-subscriber 的 tracing-log feature,代价是只用 log 的依赖进不了 tracing。
  • 栈里叠两个写同一处的 fmt 层也会每行两遍;库里抢先 try_init 不会重复,只会让你的配置不生效。

💬 关联讨论

发起讨论
加载中...