每条日志打印两次
主线位置:事件主线的两条旁路接成了一个环。
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,所以不重复。
问: 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)。
修法
按优先顺序:
去掉
log-always。 是你自己的 crate 开的就删掉,改成log或什么都不开;是第三方库开的,看它有没有把这个 feature 做成可选的(库自己的某个 feature 转发到tracing/log-always),关掉它,或者给上游提 issue。去不掉,就不装
LogTracer。 用tracing::subscriber::set_global_default(fmt().finish())(上面的set-global),或者给 tracing-subscriber 写default-features = false再列出需要的 feature、不含tracing-log。代价是只用log的那些依赖的记录进不了 tracing,和 log 互通 的表里fmt-global那一行就是这个结果。还要
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 noisylibfrom 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-logfeature,代价是只用log的依赖进不了 tracing。- 栈里叠两个写同一处的
fmt层也会每行两遍;库里抢先try_init不会重复,只会让你的配置不生效。