Rust in Everything
基于 tracing-subscriber 0.3.23

fmt

主线位置:两条主线的终点。事件主线的最后一步是 Layered::event 调到 fmt::Layer::on_event,把事件拼成一行文字写进 MakeWriter 给的 writer。span 主线上,fmt::Layer 在 on_new_span / on_record 把 span 的字段预先格式化好、挂到 Registry 的 Extensions 上;开了 with_span_events 时,它还在 on_enter / on_exit 里计时,在 on_close 输出 time.busy / time.idle。

flowchart TB
  newspan["on_new_span"] --> ff["FormattedFields N"]
  ff --> ext["span 的 Extensions"]
  rec["on_record"] -->|追加| ff
  ev["on_event"] --> buf["线程局部 BUF"]
  buf --> fe["FormatEvent::format_event"]
  ext -->|"读 span 字段"| fe
  fe --> mw["make_writer_for(meta)"]
  mw --> wa["write_all 一次"]
  wa --> clr["buf.clear()"]

fmt 是 tracing-subscriber 里最常用的部分:tracing_subscriber::fmt().init() 一行装上的就是它。它分成三块:fmt::Layer(fmt/fmt_layer.rs)决定什么时候格式化、存在哪;format 模块(fmt/format/mod.rs)决定格式化成什么样;writer 模块(fmt/writer.rs)决定写到哪。Layer trait 本身和 Layered 怎么转发回调见 Layer 与 Layered,Extensions 和 span 栈见 Registry,过滤见 过滤:全局与每层 和 EnvFilter。

本页的实验都在 M2 Max、rustc 1.99.0 上跑,依赖:

tracing = "=0.1.44"
tracing-subscriber = { version = "=0.3.23", features = ["json"] }

json 只有「四种格式」一节要用。示例都调了 .with_ansi(false),输出里没有颜色码;有几个还调了 .without_time() 省掉时间戳。

fmt::Layer 的四个类型参数

fmt::Layer 的定义:

pub struct Layer<
    S,
    N = format::DefaultFields,
    E = format::Format<format::Full>,
    W = fn() -> io::Stdout,
> {
    make_writer: W,
    fmt_fields: N,
    fmt_event: E,
    fmt_span: format::FmtSpanConfig,
    is_ansi: bool,
    ansi_sanitization: bool,
    log_internal_errors: bool,
    _inner: PhantomData<fn(S)>,
}
参数约束默认管什么
SSubscriber + for<'a> LookupSpan<'a>无,由 .with(...) 推出来下面那个 subscriber。要求 LookupSpan 是因为格式化时要从 Registry 取 span 的名字、父 span 和 Extensions
Nfor<'writer> FormatFields<'writer>DefaultFields一组字段怎么写成文字:name=value,用空格隔开
EFormatEvent<S, N>Format<Full>一整行怎么拼:时间戳、级别、span、target、字段
Wfor<'writer> MakeWriter<'writer>fn() -> io::Stdout每条事件要一个 io::Write,写完就丢

除了 S,三个参数都是「换一种做法」的扩展点,.compact()、.pretty()、.json()、.with_writer(...)、.fmt_fields(...)、.event_format(...) 这些方法换的就是它们,所以每调一次,类型就变一次。例如 pretty 同时换掉 N 和 E,返回 Layer<S, format::Pretty, format::Format<format::Pretty, T>, W>。参数在类型上,on_event 里调用 format_event 就是静态分派,可以内联。代价是类型名很长,而且装好以后换不了格式,只能用 reload 整层替换,见 reload。

两个 trait 的签名(FormatEvent、FormatFields):

pub trait FormatEvent<S, N>
where
    S: Subscriber + for<'a> LookupSpan<'a>,
    N: for<'a> FormatFields<'a> + 'static,
{
    /// Write a log message for [`Event`] in [`FmtContext`] to the given [`Writer`].
    fn format_event(
        &self,
        ctx: &FmtContext<'_, S, N>,
        writer: Writer<'_>,
        event: &Event<'_>,
    ) -> fmt::Result;
}

pub trait FormatFields<'writer> {
    /// Format the provided `fields` to the provided [`Writer`], returning a result.
    fn format_fields<R: RecordFields>(&self, writer: Writer<'writer>, fields: R) -> fmt::Result;

    fn add_fields(
        &self,
        current: &'writer mut FormattedFields<Self>,
        fields: &span::Record<'_>,
    ) -> fmt::Result {
        // ...
    }
}

FormatEvent 拿到的 FmtContext 里装着 Context(能查 span)、字段格式化器 N 和事件本身。Writer 是对 &mut dyn fmt::Write 的包装,多带两个开关:要不要输出 ANSI 颜色、要不要转义消息里的 ANSI 控制字符。FormatEvent 写的是 fmt::Write,不是 io::Write:格式化只往内存里写字符串,真正的 I/O 留到最后一步。

on_new_span:字段在 span 创建时就格式化好

span 的字段在事件里要反复打印:一个请求 span 下面有一百条事件,行首就有一百次 request{id=7 path="/search"}。fmt::Layer 不在每条事件里重新格式化,而是在 on_new_span 里格式化一次,存进 span 的 Extensions:

    fn on_new_span(&self, attrs: &Attributes<'_>, id: &Id, ctx: Context<'_, S>) {
        let span = ctx.span(id).expect("Span not found, this is a bug");
        let mut extensions = span.extensions_mut();

        if extensions.get_mut::<FormattedFields<N>>().is_none() {
            let mut fields = FormattedFields::<N>::new(String::new());
            fields.was_ansi = self.is_ansi;
            fields.was_ansi_sanitized = self.ansi_sanitization;
            if self
                .fmt_fields
                .format_fields(fields.as_writer(), attrs)
                .is_ok()
            {
                extensions.insert(fields);
            } else {
                // ... eprintln! 一条错误
            }
        }
        // ... 下面是 with_span_events 的计时和 NEW 事件,后面讲
    }

FormattedFields<E> 就是一个 String 加两个布尔值,类型参数 E 只出现在 PhantomData<fn(E)> 里。Extensions 是按类型存取的表(见 Registry),所以 FormattedFields<DefaultFields> 和 FormattedFields<format::Pretty> 是两个键,用不同字段格式化器的两个 fmt::Layer 各存各的,互不覆盖。之后 Span::record 补字段时,on_record 调 add_fields,默认实现是先补一个空格、再把新字段接在后面。

在 fmt::Layer 外面再套一层,在 on_new_span 和 on_record 里把这个 String 打印出来。Layered 先调内层再调外层,所以外层看到的是 fmt 处理完以后的 Extensions:

use tracing::span::{Attributes, Id, Record};
use tracing::{Subscriber, field, info, info_span};
use tracing_subscriber::fmt::{self, FormattedFields, format::DefaultFields};
use tracing_subscriber::layer::{Context, Layer};
use tracing_subscriber::prelude::*;
use tracing_subscriber::registry::LookupSpan;

// 放在 fmt::Layer 外面一层:Layered 先调内层,所以它看到的是 fmt 处理完之后的 Extensions
struct Peek;

impl Peek {
  fn show<S: Subscriber + for<'a> LookupSpan<'a>>(&self, hook: &str, id: &Id, ctx: &Context<'_, S>) {
    let span = ctx.span(id).unwrap();
    let ext = span.extensions();
    let fields = ext.get::<FormattedFields<DefaultFields>>();
    println!("[peek {hook}] {}: {:?}", span.name(), fields.map(|f| f.fields.as_str()));
  }
}

impl<S: Subscriber + for<'a> LookupSpan<'a>> Layer<S> for Peek {
  fn on_new_span(&self, _: &Attributes<'_>, id: &Id, ctx: Context<'_, S>) {
    self.show("on_new_span", id, &ctx);
  }
  fn on_record(&self, id: &Id, _: &Record<'_>, ctx: Context<'_, S>) {
    self.show("on_record", id, &ctx);
  }
}

fn main() {
  tracing_subscriber::registry()
    .with(fmt::layer().with_ansi(false).without_time())
    .with(Peek)
    .init();

  let span = info_span!("login", method = "oauth", user = field::Empty);
  let _g = span.enter();
  info!("before record");
  span.record("user", "alice");
  info!("after record");
}
[peek on_new_span] login: Some("method=\"oauth\"")
 INFO login{method="oauth"}: extensions: before record
[peek on_record] login: Some("method=\"oauth\" user=\"alice\"")
 INFO login{method="oauth" user="alice"}: extensions: after record

user = field::Empty 在创建时没有值,DefaultFields 跳过它;record 之后才追加上去。事件行首的 login{...} 原样取自这个字符串。

on_new_span 开头那句 if extensions.get_mut::<FormattedFields<N>>().is_none() 的意思是:同一种 N 只格式化一次,谁先到谁写。两个 fmt::Layer 用同一个 N(都是默认的 DefaultFields)时,后一层直接复用前一层写好的字符串。

📝NOTE
没有蠢问题

问: 终端一层开着颜色,文件一层 .with_ansi(false),为什么文件里 span 字段还带着颜色码?

答: 就是因为上面那句「谁先到谁写」。两层的 N 都是 DefaultFields,span 的字段只被格式化一次,用的是先跑的那层的 ANSI 设置:

use tracing::{info, info_span};
use tracing_subscriber::{fmt, prelude::*};

fn main() {
  tracing_subscriber::registry()
    .with(fmt::layer().with_ansi(true).without_time().with_writer(std::io::stderr))
    .with(fmt::layer().with_ansi(false).without_time()) // 假装这是写文件的那一层
    .init();

  let span = info_span!("req", id = 7);
  let _g = span.enter();
  info!("hello");
}

只看 stdout(2>/dev/null | cat -v):

 INFO req{^[[3mid^[[0m^[[2m=^[[0m7}: two_fmt: hello

事件本身的字段是每次现格式化的,按本层设置不带颜色;req{...} 里的 id 却是斜体加暗色的转义序列。FormattedFields 记下了 was_ansi,但只在 as_writer 继续往里追加时用,读的那一方不检查。这是 #1817,至今 open。绕开的办法是让两层的 N 是不同的类型,Extensions 里就成了两个键。

on_event:线程局部的 String

on_event 是事件主线的最后一站:

    fn on_event(&self, event: &Event<'_>, ctx: Context<'_, S>) {
        thread_local! {
            static BUF: RefCell<String> = const { RefCell::new(String::new()) };
        }

        BUF.with(|buf| {
            let borrow = buf.try_borrow_mut();
            let mut a;
            let mut b;
            let mut buf = match borrow {
                Ok(buf) => {
                    a = buf;
                    &mut *a
                }
                _ => {
                    b = String::new();
                    &mut b
                }
            };

            let ctx = self.make_ctx(ctx, event);
            if self
                .fmt_event
                .format_event(
                    &ctx,
                    format::Writer::new(&mut buf)
                        .with_ansi(self.is_ansi)
                        .with_ansi_sanitization(self.ansi_sanitization),
                    event,
                )
                .is_ok()
            {
                let mut writer = self.make_writer.make_writer_for(event.metadata());
                let res = io::Write::write_all(&mut writer, buf.as_bytes());
                if self.log_internal_errors {
                    if let Err(e) = res {
                        eprintln!("[tracing-subscriber] Unable to write an event to the Writer for this Subscriber! Error: {}\n", e);
                    }
                }
            } else if self.log_internal_errors {
                // ... 格式化失败时写一条错误说明
            }

            buf.clear();
        });
    }

三件事:

  1. 整行先拼进内存。format_event 往 BUF 里写,写完才 make_writer_for 拿 writer、write_all 一次。一行日志对 writer 只是一次写,多个线程同时打日志时,只要 writer 的一次 write_all 不被打断(Stdout 每次写会加锁),行和行之间就不会交错。
  2. BUF 用完 clear,不释放。String::clear 只把长度归零,容量留着,同一线程的下一条事件直接复用。字段:Field、Value 与 Visit 一页实测过:一万条事件,第一轮 5 次分配、第二轮 0 次,那 5 次就是各线程的 BUF 长到够用的过程。反过来,某条事件特别长,这个线程的 BUF 就一直占着那么大的容量。
  3. try_borrow_mut 失败就换一个临时 String。什么时候会已经被借走?格式化某个字段时又触发了一条事件,同一线程第二次进入 on_event。这时外层还借着 BUF,内层只能自己新建一个。

写出错默认不吭声,log_internal_errors 为真才 eprintln!。这个开关的默认值两条路径不一样:fmt::Layer 的 Default 是 false,SubscriberBuilder 的 Default 调了 .log_internal_errors(true)(#3620 报的就是这处不一致)。所以 fmt().init() 的程序把 stdout 接给提前退出的 head 时,会在 stderr 看到:

[tracing-subscriber] Unable to write an event to the Writer for this Subscriber! Error: Broken pipe (os error 32)
❗IMPORTANT
动脑筋:字段的 Debug 里又打了一条日志,哪一行先出现?平台 M2 Max、rustc 1.99.0。
use std::fmt;

use tracing::info;

struct Noisy;

impl fmt::Debug for Noisy {
  fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
    info!("from inside Debug");
    f.write_str("Noisy")
  }
}

fn main() {
  tracing_subscriber::fmt().with_ansi(false).without_time().init();
  info!(value = ?Noisy, "outer event");
}

两行输出,outer event 和 from inside Debug 谁在上面?会不会因为 BUF 已经被借走而 panic?

Full 格式:一行是怎么拼出来的

本站 crates/app/src/main.rs 里搜索索引刷新失败时打这么一行:

Err(e) => tracing::warn!(error = %e, "search: index refresh failed"),

它在 app crate 的根模块里,target 是 app。用默认的 fmt() 复现:

fn main() {
  tracing_subscriber::fmt().with_ansi(false).init();
  let e = std::io::Error::other("connection refused");
  // 本站 crates/app/src/main.rs 里那一行;target 写死成 "app",等同于在 app crate 根模块里调用
  tracing::warn!(target: "app", error = %e, "search: index refresh failed");
}

cat -vet 显示行尾:

2026-10-23T18:16:47.781508Z  WARN app: search: index refresh failed error=connection refused$

Format<Full, T> 的 format_event 按固定顺序往 Writer 里写,没开的部分整段跳过:

        self.format_timestamp(&mut writer)?;

        if self.display_level {
            // ...
            write!(writer, "{} ", fmt_level)?;
        }
        // ... display_thread_name / display_thread_id,默认关
        if let Some(scope) = ctx.event_scope() {
            // ...
            for span in scope.from_root() {
                write!(writer, "{}", bold.paint(span.metadata().name()))?;
                seen = true;

                let ext = span.extensions();
                if let Some(fields) = &ext.get::<FormattedFields<N>>() {
                    if !fields.is_empty() {
                        write!(writer, "{}{}{}", bold.paint("{"), fields, bold.paint("}"))?;
                    }
                }
                write!(writer, "{}", dimmed.paint(":"))?;
            }

            if seen {
                writer.write_char(' ')?;
            }
        };

        if self.display_target {
            write!(
                writer,
                "{}{} ",
                dimmed.paint(meta.target()),
                dimmed.paint(":")
            )?;
        }
        // ... display_filename / display_line_number,默认关
        ctx.format_fields(writer.by_ref(), event)?;
        writeln!(writer)

逐段对照:

输出由谁写说明
2026-10-23T18:16:47.781508Z + 空格format_timestamp默认计时器 SystemTime 把 std::time::SystemTime::now() 转成 UTC,DateTime 的 Display 按 {:04}-{:02}-{:02}T{:02}:{:02}:{:02}.{:06}Z 写,精确到微秒,没有时区换算,所以总是 Z
WARN + 空格FmtLevel级别字符串是常量 WARN_STR = " WARN",INFO 也带一个前导空格,凑成和 ERROR、DEBUG、TRACE 一样的 5 个字符,各行的 target 才对得齐。时间戳后面那两个空格就是这么来的
(空)span 循环这条事件不在任何 span 里,event_scope() 是 None。在 span 里时从根到叶写 名字{字段}:,字段直接取 Extensions 里的 FormattedFields<N>
app: + 空格display_targetMetadata::target(),宏默认填 module_path!()
search: index refresh failedDefaultFields 写 message 字段DefaultVisitor::record_debug 遇到名为 message 的字段只写值、不写名字。格式化消息是 fmt::Arguments,用 {:?} 输出也不带引号
error=connection refused同一个 visitor 写其余字段maybe_pad 补一个空格,再写 name=value;%e 是 DisplayValue,它的 Debug 转发到 Display,所以没有引号。同一行写成 ?e,实测输出 error=Custom { kind: Other, error: "connection refused" }
$(换行)writeln!每行以 \n 结尾,write_all 一次写出去

消息里的 search: 前缀是本站自己的写法,不是 span;如果把它换成 info_span!("search") 包住,这一段会出现在 target 之前,写成 search:。

四种格式

同一段程序,只换格式。两个嵌套 span,里面一条 info!、一条 warn!:

use tracing::{info, info_span, warn};

fn work() {
  let req = info_span!("request", id = 7, path = "/search");
  let _r = req.enter();
  let q = info_span!("query", q = "tokio");
  let _q = q.enter();
  info!(hits = 3, "search done");
  warn!(error = %"timeout", "index refresh failed");
}

fn main() {
  let which = std::env::args().nth(1).unwrap_or_default();
  let b = tracing_subscriber::fmt().with_ansi(false);
  match which.as_str() {
    "full" => b.init(),
    "compact" => b.compact().init(),
    "pretty" => b.pretty().init(),
    "json" => b.json().init(),
    _ => panic!("full|compact|pretty|json"),
  }
  work();
}

Full(默认):

2026-10-23T18:15:11.343430Z  INFO request{id=7 path="/search"}:query{q="tokio"}: formats: search done hits=3
2026-10-23T18:15:11.343542Z  WARN request{id=7 path="/search"}:query{q="tokio"}: formats: index refresh failed error=timeout

Compact:

2026-10-23T18:15:11.346436Z  INFO request:query: formats: search done hits=3 id=7 path="/search" q="tokio"
2026-10-23T18:15:11.346450Z  WARN request:query: formats: index refresh failed error=timeout id=7 path="/search" q="tokio"

Pretty:

  2026-10-23T18:15:11.349214Z  INFO formats: search done, hits: 3
    at src/bin/formats.rs:8
    in formats::query with q: "tokio"
    in formats::request with id: 7, path: "/search"

  2026-10-23T18:15:11.349231Z  WARN formats: index refresh failed, error: timeout
    at src/bin/formats.rs:9
    in formats::query with q: "tokio"
    in formats::request with id: 7, path: "/search"

Json(每条一行):

{"timestamp":"2026-10-23T18:15:11.351848Z","level":"INFO","fields":{"message":"search done","hits":3},"target":"formats","span":{"q":"tokio","name":"query"},"spans":[{"id":7,"path":"/search","name":"request"},{"q":"tokio","name":"query"}]}
{"timestamp":"2026-10-23T18:15:11.351867Z","level":"WARN","fields":{"message":"index refresh failed","error":"timeout"},"target":"formats","span":{"q":"tokio","name":"query"},"spans":[{"id":7,"path":"/search","name":"request"},{"q":"tokio","name":"query"}]}
格式N(字段格式化器)E 的实现span 的写法
FullDefaultFieldsFormat<Full, T>行首,名字{字段}: 从根到叶
CompactDefaultFieldsFormat<Compact, T>行首只写名字 request:query:,各 span 的字段从根到叶接在事件字段后面
Prettyformat::PrettyFormat<Pretty, T>多行:at 文件:行,然后从叶到根每个 span 一行 in target::名字 with 字段
JsonJsonFieldsFormat<Json, T>默认同时输出当前 span(span)和整条链(spans)

Compact 不换 N,所以它和 Full 共用同一份 FormattedFields<DefaultFields>,差别只在 format_event 把字符串放在哪。Pretty 和 Json 连 N 一起换了:Pretty 的字段写成 name: value、用逗号隔开,Json 的 FormattedFields<JsonFields> 里存的是一段 JSON 对象文本。

Json 的 span 字段存成字符串是有代价的。输出 span 时,SerializableSpan::serialize 先 serde_json::from_str 把这段文本解析回 serde_json::Value,再一个个字段写出去,源码注释直说「TODO: let's not do this」。JsonFields::add_fields 补字段时也是解析、合并、再序列化。根子在 FormattedFields 只能存 String:这套存储是为文本格式设计的,JSON 是后来套上去的。时间格式(with_timer、time / chrono 两个可选实现)和 Json 的 flatten_event 等选项不在本卷展开。

with_span_events:NEW、CLOSE 与 busy / idle

默认 fmt 只输出事件,span 只作为事件的上下文出现。with_span_events 让它在 span 生命周期的几个点上自己「造」一条事件。FmtSpan 是一个 u8 位集:

常量位什么时候输出
NEW1 << 0on_new_span,消息 new
ENTER1 << 1on_enter,消息 enter
EXIT1 << 2on_exit,消息 exit
CLOSE1 << 3on_close,消息 close,带 time.busy、time.idle
NONE0默认
ACTIVEENTER | EXIT
FULL四个都有

造事件的是 with_event_from_span! 宏:用 span 自己的 Metadata 和调用点,现场建一个只有 message(和两个计时字段)的 FieldSet,Event::new_child_of(id, ...) 造一个以这个 span 为父的事件,然后直接调 self.on_event。它不经过 dispatcher,别的 layer 看不到这条事件,级别和 target 也都是 span 的。

计时用的是挂在 Extensions 里的 Timings:

struct Timings {
    idle: u64,
    busy: u64,
    last: Instant,
    entered_count: u64,
}

它在 on_new_span 里插入,last 取当时的 Instant::now();之后的规则(on_enter、on_exit、on_close):

            // on_enter
            if let Some(timings) = extensions.get_mut::<Timings>() {
                if timings.entered_count == 0 {
                    let now = Instant::now();
                    timings.idle += (now - timings.last).as_nanos() as u64;
                    timings.last = now;
                }
                timings.entered_count += 1;
            }

            // on_exit
            if let Some(timings) = extensions.get_mut::<Timings>() {
                timings.entered_count -= 1;
                if timings.entered_count == 0 {
                    let now = Instant::now();
                    timings.busy += (now - timings.last).as_nanos() as u64;
                    timings.last = now;
                }
            }
  • idle:span 存在、但没有任何一层 enter 的时间。从创建到第一次进入、两次进入之间、最后一次退出到关闭,都算 idle。
  • busy:至少有一层 enter 的时间,从 entered_count 由 0 变 1 起,到它回到 0 止。
  • 两者都用单调时钟 Instant,单位纳秒,on_close 再补上最后一段 idle,然后用 TimingDisplay 按量级写成 45.0ms、1.88µs 这样的三位有效数字。

在 async 代码里 span 由 Instrumented 在每次 poll 时进出(见 Instrument 与异步),所以 busy 是这个 future 真正被 poll 的时间,idle 是它挂起等待的时间。

实测,thread::sleep 制造已知的时长:

use std::thread::sleep;
use std::time::Duration;

use tracing::info_span;
use tracing_subscriber::fmt::format::FmtSpan;

fn ms(n: u64) -> Duration {
  Duration::from_millis(n)
}

fn main() {
  tracing_subscriber::fmt()
    .with_ansi(false)
    .with_span_events(FmtSpan::NEW | FmtSpan::CLOSE)
    .init();

  let span = info_span!("job", id = 1);
  sleep(ms(30)); // 创建后、第一次进入前:idle
  span.in_scope(|| sleep(ms(20))); // busy
  sleep(ms(40)); // 两次进入之间:idle
  span.in_scope(|| sleep(ms(10))); // busy
  drop(span); // 最后一次退出到关闭:几乎为 0 的 idle
}

跑 3 次:

2026-10-23T18:15:57.832382Z  INFO job{id=1}: span_events: new
2026-10-23T18:15:57.960686Z  INFO job{id=1}: span_events: close time.busy=45.0ms time.idle=83.3ms
2026-10-23T18:15:58.138168Z  INFO job{id=1}: span_events: new
2026-10-23T18:15:58.264281Z  INFO job{id=1}: span_events: close time.busy=36.7ms time.idle=89.4ms
2026-10-23T18:15:58.451977Z  INFO job{id=1}: span_events: new
2026-10-23T18:15:58.586818Z  INFO job{id=1}: span_events: close time.busy=45.0ms time.idle=89.8ms

程序里写的是 busy 30 ms、idle 70 ms。实测两项都偏大且每次不同,因为 thread::sleep 只保证至少睡这么久,这台机器上每次多睡几毫秒到十几毫秒;比例是稳定的,idle 约为 busy 的两倍。

📝NOTE
没有蠢问题

问: 照着写了 .with_span_events(FmtSpan::CLOSE),close 那行却没有 time.busy 和 time.idle,为什么?

答: 多半还调了 .without_time()。without_time 除了去掉时间戳,还调用 self.fmt_span.without_time(),把 FmtSpanConfig 的 fmt_timing 设成 false;on_new_span 只在 fmt_timing && trace_close() 时插入 Timings,on_close 找不到它,就走不带计时字段的分支。上面的程序加一行 .without_time() 的输出:

 INFO job{id=1}: span_events: new
 INFO job{id=1}: span_events: close

with_span_events 的文档也写了「If timestamps are enabled for this formatter」才有计时。想要计时又不要时间戳,用 .with_timer(()):它只把计时器换成什么都不写的 (),不碰 fmt_timing。代价是 display_timestamp 仍为真,时间戳后面那个空格照写,每行多一个前导空格:

tracing_subscriber::fmt()
  .with_ansi(false)
  .with_timer(())
  .with_span_events(FmtSpan::CLOSE)
  .init();
let span = tracing::info_span!("job");
span.in_scope(|| tracing::info!("working"));
  INFO job: timer_unit: working
  INFO job: timer_unit: close time.busy=116µs time.idle=792ns

同一个 span 可以被嵌套进入两次,entered_count 就是为这种情况加的。下面的程序里外层守卫 _a 一直持有,中间又 enter 了一次:

use std::thread::sleep;
use std::time::Duration;

use tracing::info_span;
use tracing_subscriber::fmt::format::FmtSpan;

fn main() {
  tracing_subscriber::fmt()
    .with_ansi(false)
    .with_span_events(FmtSpan::CLOSE)
    .init();

  let span = info_span!("outer");
  let _a = span.enter();
  sleep(Duration::from_millis(50));
  {
    let _b = span.enter(); // 同一个 span 再进一次
    sleep(Duration::from_millis(50));
  }
  sleep(Duration::from_millis(50));
  drop(_a);
  drop(span);
}

跑 3 次:

2026-10-23T18:15:58.132072Z  INFO outer: reenter: close time.busy=167ms time.idle=999ns
2026-10-23T18:15:58.448521Z  INFO outer: reenter: close time.busy=181ms time.idle=1.88µs
2026-10-23T18:15:58.767473Z  INFO outer: reenter: close time.busy=178ms time.idle=1.88µs

span 从进入到关闭一直至少有一层 enter,三段 50 ms 全算 busy(同样多睡了一些),idle 只剩创建到进入、退出到关闭的一两微秒。

📝NOTE
冷知识:嵌套进入曾经把 busy 算成 idle

0.3.19 及以前,Timings 没有 entered_count,on_enter 每次都把「距上次的时间」加到 idle,on_exit 每次都加到 busy。#2944 的说明:「The timings measurements were done naively in a way where they added busy time in on_exit and idle time in on_enter. This works only if the spans are not entered multiple times.」它举的例子是进入、等 1 秒、再进入、退出、退出,结果是「about 1 second of idle time and no busy time」。改法:「Count the relative number of enters and exits and only increment the timers on top-level enters and exits.」按旧代码推算上面那个程序:第二次 enter 把前 50 ms 记成 idle,两次 exit 各记 50 ms busy,结果约为 busy 100 ms、idle 50 ms。这个 PR 合进的是 master,0.1 线上第一个带它的是 tracing-subscriber 0.3.20,CHANGELOG 没有单独记这一条。on_close 里的 debug_assert_eq!(entered_count, 0) 也是这次加的。

MakeWriter:每条事件要一个 writer

MakeWriter 只有一个必须实现的方法:

pub trait MakeWriter<'a> {
    type Writer: io::Write;

    fn make_writer(&'a self) -> Self::Writer;

    fn make_writer_for(&'a self, meta: &Metadata<'_>) -> Self::Writer {
        let _ = meta;
        self.make_writer()
    }
}

on_event 每条事件调一次 make_writer_for,写完 writer 就 drop。设计成「工厂」而不是直接持有一个 io::Write,是因为 on_event 拿到的是 &self,又可能在多个线程上同时跑:每次现做一个 writer,Stdout 这类 writer 就能各自加锁;'a 生命周期让 writer 可以借用工厂本身(Arc<W>、File 的实现返回 &W)。make_writer_for 多给一个 Metadata,让工厂能按级别、target 选出口。

实现make_writer 给什么
F: Fn() -> W调一次闭包。默认的 W = fn() -> io::Stdout 就是 io::stdout 这个函数
Arc<W>、File&W、&File,要求 &W: io::Write
Mutex<W>加锁,返回守卫
TestWriter一个新的 TestWriter,它的 write 用 print!

TestWriter 的 io::Write 把字节转成字符串再 print!。差别在于 libtest 的输出捕获挂在 print! 这条路径上,直接写 io::stdout() 绕过它,所以 with_test_writer() 的日志只在测试失败时显示,默认的 stdout 则总是混在测试输出里。0.3.20 起(#3187)有 TestWriter::with_stderr(),可在 0.3.23 里 MakeWriter for TestWriter 的 make_writer 返回的是 Self::default(),use_stderr 被丢掉。把 .with_writer(TestWriter::with_stderr()) 的程序 stdout、stderr 分开看:

stdout only:
 INFO test_writer_stderr: where do I go?
stderr only:

还是写到了 stdout。

组合子:with_max_level、or_else、and

MakeWriterExt 给所有 MakeWriter 加了组合方法,都在 make_writer_for 里看 Metadata 做决定:

  • with_max_level(level):返回 WithMaxLevel,它的 Writer 是 OptionalWriter<W>(即 EitherWriter<W, io::Sink>)。级别不超过 level 给真 writer,否则给 io::sink()。注意无参的 make_writer 不知道级别,一律给 sink。with_min_level、with_filter 同理。
  • or_else(other):只接受 Writer = OptionalWriter<_> 的工厂;前者给了 sink,就改用 other。
  • and(other):返回 Tee,同一行写两份。impl_tee! 先让两边都写完,再用 ? 检查结果,所以第一个出错不会让第二个少写。

用一个给每次 write 加前缀的 writer 看出每行进了哪个出口:

use std::io::{self, Write};

use tracing::{Level, debug, error, info, warn};
use tracing_subscriber::fmt::writer::MakeWriterExt;

// 一个给每次 write 加前缀的 writer,用来看出一行进了哪个出口
struct Tagged(&'static str);

impl Write for Tagged {
  fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
    let mut out = io::stdout().lock();
    out.write_all(self.0.as_bytes())?;
    out.write_all(buf)?;
    Ok(buf.len())
  }
  fn flush(&mut self) -> io::Result<()> {
    Ok(())
  }
}

fn main() {
  let alerts = (|| Tagged("[alerts] ")).with_max_level(Level::WARN);
  let console = || Tagged("[console] ");
  let audit = || Tagged("[audit] ");

  // WARN 及以上进 alerts,其余进 console;每一行另外抄一份给 audit
  let make_writer = alerts.or_else(console).and(audit);

  tracing_subscriber::fmt()
    .with_ansi(false)
    .without_time()
    .with_max_level(Level::DEBUG)
    .with_writer(make_writer)
    .init();

  debug!("cache miss");
  info!("request done");
  warn!("slow query");
  error!("db down");
}
[console] DEBUG writers: cache miss
[audit] DEBUG writers: cache miss
[console]  INFO writers: request done
[audit]  INFO writers: request done
[alerts]  WARN writers: slow query
[audit]  WARN writers: slow query
[alerts] ERROR writers: db down
[audit] ERROR writers: db down

with_max_level(WARN) 的「max」按 Level 的大小比:TRACE 最大、ERROR 最小(见 Metadata、Level 与 LevelFilter),所以「不超过 WARN」是 WARN 和 ERROR。这些组合都只决定一行写到哪,事件仍然被格式化了一遍;想让某个出口根本不处理某些事件,要用每层过滤(过滤:全局与每层),给不同出口各装一个 fmt::Layer。

SubscriberBuilder、init 与 try_init

tracing_subscriber::fmt() 返回 SubscriberBuilder,它只有两个字段:

pub struct SubscriberBuilder<
    N = format::DefaultFields,
    E = format::Format<format::Full>,
    F = LevelFilter,
    W = fn() -> io::Stdout,
> {
    filter: F,
    inner: Layer<Registry, N, E, W>,
}

一个 fmt::Layer 加一个过滤器。builder 上的格式方法转给 inner,with_env_filter 和 with_max_level 换掉 filter。默认过滤器是 DEFAULT_MAX_LEVEL,即 LevelFilter::INFO,所以什么都不配的 fmt().init() 不输出 debug!。finish 把它们搭起来:

    pub fn finish(self) -> Subscriber<N, E, F, W> {
        let subscriber = self.inner.with_subscriber(Registry::default());
        Subscriber {
            inner: self.filter.with_subscriber(subscriber),
        }
    }

得到 fmt::Subscriber { inner: Layered<F, Layered<fmt::Layer<Registry, N, E, W>, Registry>> }。和手写 registry().with(fmt::layer()).with(filter) 的区别只有两点:外面多包一层 fmt::Subscriber 这个新类型,它的 Subscriber 实现逐个方法转给 inner;log_internal_errors 默认是 true。filter 在最外层,按 过滤:全局与每层 的说法是全局过滤,它说「不」,fmt::Layer 就看不到。

SubscriberBuilder::try_init 先 finish(),再交给 SubscriberInitExt::try_init,它做两件事:

    fn try_init(self) -> Result<(), TryInitError> {
        dispatcher::set_global_default(self.into()).map_err(TryInitError::new)?;

        // Since we are setting the global default subscriber, we can
        // opportunistically go ahead and set its global max level hint as
        // the max level for the `log` crate as well. This should make
        // skipping `log` diagnostics much faster.
        #[cfg(feature = "tracing-log")]
        tracing_log::LogTracer::builder()
            // Note that we must call this *after* setting the global default
            // subscriber, so that we get its max level hint.
            .with_max_level(tracing_core::LevelFilter::current().as_log())
            .init()
            .map_err(TryInitError::new)?;

        Ok(())
    }
  1. self.into() 走 Dispatch::new,新建 dispatcher 时就重算了所有调用点的 Interest 和全局 MAX_LEVEL;set_global_default 再把它设成全局默认,只能成功一次(见 Dispatch:全局与线程局部)。
  2. tracing-subscriber 默认开着 tracing-log feature,于是再装 LogTracer 当 log crate 的 logger,把 log::info! 之类转成 tracing 事件,并用刚算出的 LevelFilter::current() 设 log 的最大级别(见 和 log 互通)。

init 就是 try_init 加 expect。验证两件事和第二次 init 的 panic:

fn main() {
  println!("log::max_level before = {}", log::max_level());
  tracing_subscriber::fmt().with_max_level(tracing::Level::DEBUG).init();
  println!("log::max_level after  = {}", log::max_level());

  let err = tracing_subscriber::fmt().try_init().unwrap_err();
  println!("try_init: {err}");
  println!("try_init: {err:?}");

  tracing_subscriber::fmt().init();
}

(另加依赖 log = "0.4"。)

log::max_level before = OFF
log::max_level after  = DEBUG
try_init: a global default trace dispatcher has already been set
try_init: SetGlobalDefaultError("a global default trace dispatcher has already been set")

thread 'main' (1403719) panicked at ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/tracing-subscriber-0.3.23/src/fmt/mod.rs:520:14:
Unable to install global subscriber: SetGlobalDefaultError("a global default trace dispatcher has already been set")
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

TryInitError 的 Display 和 Debug 都原样转给里面装的错误,所以看到的是 tracing-core 的 SetGlobalDefaultError。panic 文字的前缀取决于走哪条路:SubscriberBuilder::init 是 Unable to install global subscriber;registry().with(...).init() 走的是 SubscriberInitExt::init,前缀不同:

thread 'main' (1434268) panicked at ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/tracing-subscriber-0.3.23/src/util.rs:94:14:
failed to set global default subscriber: SetGlobalDefaultError("a global default trace dispatcher has already been set")
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

还有一个不带参数的自由函数 tracing_subscriber::fmt::try_init(和 fmt::init):开了 env-filter 时等于 fmt().with_env_filter(EnvFilter::from_default_env()).try_init(),从 RUST_LOG 读指令;没开时退而用 RUST_LOG 解析一个 Targets。fmt().init() 不读 RUST_LOG,这是两者最容易混的地方。

📝NOTE
没有蠢问题

问: try_init() 返回了 Err,是不是说明我的 subscriber 没装上?

答: 不一定。上面那段代码里有两个 ?:第一个失败(全局默认已经有了),什么都没装;第二个失败时,set_global_default 已经成功,subscriber 已经装上了,只是 log 那边的 logger 被别人先占了。先用 log::set_logger 装一个别的 logger,再 try_init:

struct Other;
impl log::Log for Other {
  fn enabled(&self, _: &log::Metadata<'_>) -> bool {
    true
  }
  fn log(&self, r: &log::Record<'_>) {
    println!("[other logger] {}", r.args());
  }
  fn flush(&self) {}
}

fn main() {
  // 别的库已经装了一个 log 的 logger
  log::set_logger(&Other).unwrap();
  log::set_max_level(log::LevelFilter::Trace);

  let res = tracing_subscriber::fmt().with_ansi(false).without_time().try_init();
  println!("try_init -> {res:?}");
  tracing::info!("tracing event");
  log::info!("log record");
}
try_init -> Err(SetLoggerError(()))
 INFO logger_taken: tracing event
[other logger] log record

返回 Err,tracing 的事件却照常输出了。要区分两种失败,得看错误的类型,不能只看 is_err();不需要 log 转发的程序可以关掉 tracing-subscriber 的 tracing-log feature,或者改用 tracing::subscriber::set_global_default 只做第一件事。

动脑筋答案

 INFO reentrant: from inside Debug
 INFO reentrant: outer event value=Noisy

跑 3 次输出相同,没有 panic,内层那条在上面。

  • 外层 info! 进入 on_event,借走这个线程的 BUF,开始往里写 INFO reentrant: outer event value=。写 value 时调用 Noisy 的 Debug。
  • Debug 里的 info! 经全局 dispatcher 又到了同一个 fmt::Layer::on_event。全局默认没有防递归(Dispatch 一页讲过 can_enter 只管线程默认),所以它真的进来了。这时 BUF.try_borrow_mut() 失败,走 _ => { b = String::new(); &mut b },用一个临时 String 拼完整行,立刻 write_all 出去。
  • 回到外层,写完 Noisy、换行,才轮到外层的 write_all。所以先格式化的那条反而后输出。

如果 BUF 用的是 borrow_mut() 而不是 try_borrow_mut(),这里就会 panic 成 already borrowed。

小结

  • fmt::Layer<S, N, E, W>:N 写字段、E 拼一行、W 给 writer,三个都是类型参数,静态分派;.compact() / .pretty() / .json() / .with_writer() 每调一次换一个类型。
  • span 的字段在 on_new_span 格式化一次,存成 Extensions 里的 FormattedFields<N>,on_record 往后追加;事件只是拼接。同一个 N 只存一份,两层 ANSI 设置不同时会串(#1817)。
  • on_event 在线程局部 BUF: RefCell<String> 里拼完整行,make_writer_for 拿 writer、write_all 一次,clear 后保留容量;被重入时退回临时 String。
  • Full 格式的一行:UTC 微秒时间戳、补齐到 5 个字符的级别、从根到叶的 span{字段}:、target:、不带名字的 message、其余 name=value、换行。
  • with_span_events 自己造事件直接调 on_event,别的 layer 看不到;CLOSE 的 time.busy / time.idle 由 Extensions 里的 Timings 用 Instant 累加,0.3.20 起按 entered_count 只算最外层进出;without_time() 会连计时一起关掉。
  • MakeWriter 每条事件造一个 writer;with_max_level、or_else、and 在 make_writer_for 里按 Metadata 选出口。TestWriter 走 print! 才能被测试捕获;0.3.23 的 with_stderr() 不生效。
  • fmt() = Registry + fmt::Layer + 最外层过滤器(默认 INFO);try_init 先 set_global_default,再装 LogTracer 并同步 log 的最大级别,后一步失败时 subscriber 其实已经装上。

💬 关联讨论

发起讨论
加载中...