怎么读这一卷
主线位置:两条主线的总览。本页把事件主线和 span 主线各画一遍,标出每一步落在哪个函数上;下一部分的「一条日志穿过 tracing」用一个程序把两条线同时跑一遍。
flowchart TB
start[从这里开始] --> q{读这一卷为了什么}
q -->|会用| use1[01 全景]
use1 --> use2[03 tracing 门面]
use2 --> use3["04 #[instrument]"]
use3 --> use4[05 fmt 与 EnvFilter]
use4 --> use5[07 常见坑]
q -->|读懂实现| all[00 到 09 按顺序]
锁定版本
| crate | 版本 | tag | commit | 日期 |
|---|---|---|---|---|
| tracing | 0.1.44 | tracing-0.1.44 | 2d55f6fa | 2025-12-18 |
| tracing-core | 0.1.36 | tracing-core-0.1.36 | 10a9e838 | 2025-12-18 |
| tracing-attributes | 0.1.31 | tracing-attributes-0.1.31 | 55086231 | 2025-11-26 |
| tracing-subscriber | 0.3.23 | tracing-subscriber-0.3.23 | 54ede4d5 | 2026-03-13 |
| tracing-log(只在一两节出现) | 0.2.0 | tracing-log-0.2.0 | 4161d813 | 2023-10-24 |
| tracing-appender(只在常见坑出现) | 0.2.5 | tracing-appender-0.2.5 | 53e14901 | 2026-04-17 |
源码链接形如 https://github.com/tokio-rs/tracing/blob/tracing-core-0.1.36/tracing-core/src/...#L123:链到哪个 crate 的文件,就用哪个 crate 的 tag。sharded-slab、thread_local、matchers 这些不在仓库里的依赖只写 crate 名和版本。
本站自己的 Cargo.lock 锁的正好是 tracing 0.1.44、tracing-core 0.1.36、tracing-attributes 0.1.31、tracing-subscriber 0.3.23、tracing-log 0.2.0,和本卷相同,「在本站中」一部分不用讲版本差异。
为什么是四个 tag
tokio、hyper、axum 那几卷都是一个 tag 管全卷。tracing 不行:仓库里每个 crate 各自发版,发版时只给发的那个 crate 打 tag,同一个 tag 下的其他 crate 是「当时 main 上的样子」,不一定是发出去的那个版本。
tracing-0.1.44这个 tag 里,tracing-subscriber/Cargo.toml写的是version = "0.3.22",tracing-appender/Cargo.toml是0.2.4。tracing-subscriber 0.3.23 晚了将近三个月才发(2026-03-13),在 tracing 的 tag 里读不到它。- 反过来,
tracing-subscriber-0.3.23这个 tag 里的tracing-core/已经不是 0.1.36 了:git diff tracing-core-0.1.36 tracing-subscriber-0.3.23 -- tracing-core改了 5 个文件,src/lazy.rs被删掉,Cargo.toml里去掉了once_cell依赖,callsite.rs、sync.rs跟着改。这些改动没有随任何 tracing-core 版本发布,你的项目里编译的仍然是 0.1.36。
你的项目里实际编译哪个版本,看依赖要求:tracing 0.1.44 依赖 tracing-core = { ..., version = "0.1.36" },tracing-subscriber 0.3.23 依赖 tracing-core = { ..., version = "0.1.35" },都是 ^ 要求,Cargo 解析到同一个 tracing-core 0.1.36。所以每个 crate 都对着自己的 tag 读,才是你编译的那份代码。
仓库的 main 已经走过了 tag
写作时 main 比 tracing-subscriber-0.3.23 多 19 个提交,比 tracing-0.1.44 多 24 个,比 tracing-core-0.1.36 多 25 个,比 tracing-attributes-0.1.31 多 36 个。其中有直接改本卷要讲的代码的,比如「simplify check for interest before creating events/spans/logging/etc in macros」(#3626)改了宏展开里的判断,「Bump MSRV to 1.71」(#3629)。这些都不在本卷的版本里,读 main 会看到对不上的代码和行号。本卷四个 crate 的 MSRV 都是 1.65.0。
0.1 和没发布的 0.2
tag 下的根 README.md 有一节「Branch set-up」:
- main - Default branch, crates.io releases are done from this branch. This was previously the
`v0.1.x` branch.
- v0.2.x - Branch containing the as-yet unreleased 0.2 version of `tracing-core`, `tracing`, and
all the other tracing crates that depend on these versions. This was previously the `master`
branch.
也就是说,crates.io 上的 tracing 一直是 0.1(tracing-core 0.1.0 在 2019-06-27 发布,到 0.1.36 共 37 个版本),0.2 在 v0.2.x 分支上开发,至今没有发布。v0.2.x 上很多名字都改了:tracing-core 的 Subscriber trait 在那里叫 Collect(tracing-core/src/collect.rs),tracing-subscriber 的 Layer 叫 Subscribe(tracing-subscriber/src/subscribe/)。README 顶部「Documentation (v0.2.x)」那个徽章指向 tracing.rs,那里的文档对应 0.2;对照本卷看文档,要看 docs.rs 上的 0.1。本卷只讲 0.1。
分支的角色在 2025 年 6 月换过一次。#3295(2025-06-05 合并)的说明是:
The new `main` branch is forked from the `v0.1.x` branch. It will be
made the default branch and going forward we will merge PRs to this
branch first (and then forward port to a new `v0.2.x` branch forked from
`master`).
在这之前方向相反:PR 先合进 master(0.2),再移植到 v0.1.x。比如 #2820「tracing: add index API for Field」2024-11-18 合进 master,同名提交 2024-11-22 才出现在 0.1 线上,标题里带的还是原来的 PR 号。本卷的冷知识引用 PR 时,会说明这个改动在 0.1 线上第一次随哪个版本发布。
两条主线
tracing 的工作分成两件:记一条事件(info!(...))和维护 span(info_span!(...) 从创建、进入、退出到关闭)。全卷沿这两条线展开,每页开头都会标出本页在哪条主线上。
事件主线(每次 info!):
flowchart TB m["info!:static 调用点"] --> g1["门一:STATIC_MAX_LEVEL"] g1 --> g2["门二:LevelFilter::current"] g2 --> g3["门三:调用点的 Interest"] g3 -->|never| skip[什么也不做] g3 -->|always| vs[构造 ValueSet] g3 -->|sometimes| g4["门四:Subscriber::enabled"] g4 --> vs vs --> ed["Event::dispatch"] ed --> ee["event_enabled"] ee --> lay["Layered:每层 on_event"] lay --> fmtl["fmt::Layer 写一行"]
- 每个
info!都经过event!,在调用点展开成一个static __CALLSITE(类型是DefaultCallsite)和它的'static元数据:名字、target、级别、文件行号、字段名都在编译期定下来。见宏展开和 Metadata、Level 与 LevelFilter。 - 门一、门二都在
level_enabled!里:先比STATIC_MAX_LEVEL,它是由max_level_*/release_max_level_*feature 决定的常量,比不过的分支在编译期就被优化掉;再比LevelFilter::current(),它读的是全局原子变量MAX_LEVEL,值是所有 subscriber 的max_level_hint里最大的那个。见级别过滤的几道门。 - 门三是
DefaultCallsite::interest:读调用点自己缓存的Interest。第一次经过时还没有缓存,走register,问每个 dispatcher 的register_callsite,把结果合并成never/sometimes/always存起来。never到此为止。见 Callsite 与 Interest 缓存。 - 门四是
__is_enabled:always直接放行,sometimes才通过dispatcher::get_default找到当前 subscriber,问它enabled(meta)。见 Dispatch:全局与线程局部和 Subscriber trait。 - 四道门都过了,才对字段表达式求值,构造一个
ValueSet,调用Event::dispatch。Dispatch::event先问一次event_enabled(这时能看到字段值了),再调用Subscriber::event。见字段和 span 与 Event 的数据。 - 在 tracing-subscriber 里,subscriber 通常是一串
Layered。Layered::event先交给内层,再调用本层的on_event;fmt::Layer::on_event把事件格式化进一个线程局部的String,写进MakeWriter给的 writer。见 Layer 与 Layered 和 fmt。
第 3 步的注册只在第一次经过调用点时发生;之后只有新建一个 Dispatch、或者用 reload 换掉一层时,才会遍历所有调用点重算一次 Interest。前三道门每次都要过,但只是一次常量比较、一次原子读、再一次原子读。被级别挡掉、或者 Interest 是 never 的日志开销很小,因为它们停在这里,字段表达式不会求值,也不会去找当前的 subscriber。
span 主线(一个 span 的一生):
flowchart TB sm["info_span!:同样的四道门"] -->|没通过| none["空 Span:之后都空操作"] sm -->|通过| ns["new_span 分配 Id"] ns --> en["enter:压进 span 栈"] en --> ev["期间的事件找到当前 span"] ev --> ex["守卫 drop:exit 出栈"] ex --> dr["clone 与 drop:增减计数"] dr --> tc["计数归零:try_close"] tc --> oc["所有 layer 的 on_close"] oc --> fr["释放 span 的槽"]
span!也展开成一个static调用点,过和事件一样的四道门。没过的话得到一个inner为None的Span,之后对它的enter、record、drop 都不会调用 subscriber(开了logfeature 时仍可能发log记录)。见 Span 与 Entered。- 过了门,
Span::new把元数据、字段和父 span 包成span::Attributes,交给Subscriber::new_span,拿回一个span::Id。tracing 自己不存任何 span 数据,Id由 subscriber 分配:Registry::new_span从 sharded-slab 的池里取一个槽,idx_to_id把 sharded-slab 的 key 加一当作Id(key 里打包了槽地址、线程号和代数)。见 Registry。 Span::enter调用 subscriber 的enter,返回守卫Entered。Registry::enter把Id压进当前线程的 span 栈;这期间的事件没有显式指定父 span,就以栈顶为父。守卫 drop 时调用exit,Registry::exit出栈。Span的 clone 和 drop 对应 subscriber 的clone_span和try_close。Span的Drop调用try_close,Registry::try_close把引用计数减一,只有减到零才返回true。Layered::try_close在调用内层之前先向Registry要一个CloseGuard,内层确认关闭后再调用本层的on_close。守卫层层嵌套,最外层的守卫 drop 时,所有 layer 的on_close都已经跑完,这时才真正释放槽。- 异步代码里 span 不能靠
enter跨.await持有。Instrumented在每次poll时进入 span、poll返回时退出,被 drop 时也先进入一次。见 Instrument 与异步。
两条线的交点是「当前 span」:事件主线第 6 步里 fmt::Layer 打印的 span 上下文,就是 span 主线第 3 步压进栈的那些 Id。
阅读路线
- 只想会用:读 01 全景、03 tracing 门面、04 #[instrument]、05 里的 fmt 和 EnvFilter,以及常见坑。读完能写出一个带
EnvFilter的fmtsubscriber,知道RUST_LOG的指令怎么写,知道异步代码里为什么用.instrument(span)而不是span.enter()。 - 想读懂实现:按顺序读。02 是重点,两条主线上的每一步最后都落到 tracing-core 的类型上:
Metadata、调用点、Interest、Dispatch、Subscriber、字段。05 讲 tracing-subscriber 怎么把一个Subscriber拆成「存 span 的Registry」加「一层层的Layer」;06 把各章的取舍汇总。
本卷的约定
- 每页开头的「主线位置」说明本页在事件主线、span 主线上的哪一步,还是两条都不在(比如源码地图)。
- 摘自 tracing 各 crate 的代码片段一律保留原来的格式(4 空格缩进),方便和原文件对照,删掉的行用
// ...标出;本站自己写的示例用 2 空格缩进。 - 关于行为的说法(顺序、次数、哪个回调被调用、打印出什么)尽量配一个小程序和它的真实输出。每个示例上方写出它需要的依赖,版本用
=锁死,例如tracing = "=0.1.44"、tracing-subscriber = { version = "=0.3.23", features = ["env-filter"] },只开用到的 feature。实验的平台是 M2 Max、rustc 1.99.0。 - 想看 subscriber 收到了什么,示例通常自己实现一个打印每个回调的
Subscriber或Layer,比读fmt的输出更直接;引用fmt输出的示例都关掉了 ANSI 颜色。
前置知识
这一卷只要求会写 Rust,知道 trait 对象、static、原子变量和线程局部变量是什么。tracing 不依赖任何运行时,只有异步那一章和几个常见坑会碰到前几卷的内容:
| 概念 | 讲它的页 | 本卷用到它的地方 |
|---|---|---|
Future::poll 与任务 | poll 与 wake | Instrumented 在每次 poll 前后进出 span |
| 工作线程与任务窃取 | 多线程调度器、窃取与空闲 | 一个任务的几次 poll 可能在不同线程上;span 栈和 set_default 都是线程局部的 |
tower 的 Layer | Layer 与 Stack | 只是同名:tracing-subscriber 的 Layer 叠在 Subscriber 上,不是包 Service |
tower-http 的 TraceLayer | 其余常用中间件 | 每个请求一个 span,事件和 span 都走本卷讲的路径 |
tracing 仓库的 examples/ 有 40 个示例程序,tracing、tracing-core、tracing-subscriber 的 crate 文档也都很详细。读源码之前先照着写一个带 span 和字段的小程序,会轻松很多。
目录
| 部分 | 内容 |
|---|---|
| 00 导读 | 本页;源码地图 |
| 01 全景 | tracing 解决什么问题;一条日志穿过 tracing |
| 02 tracing-core | 本卷重点:Metadata 与 Level、调用点与 Interest、Dispatch、Subscriber trait、字段、span 与 Event |
| 03 tracing 门面 | 宏展开、级别过滤的几道门、Span 与 Entered、Instrument、和 log 互通 |
| 04 #[instrument] | 属性宏展开成什么;它的参数 |
| 05 tracing-subscriber | Layer 与 Layered、Registry、全局与每层过滤、EnvFilter、fmt、reload |
| 06 设计取舍 | 把各章的取舍汇总到一起 |
| 07 常见坑 | 用报错原文或现象做标题,讲它在源码里怎么产生 |
| 08 在本站中 | 本站(开源)代码里怎么装 subscriber、怎么用 #[instrument] |
| 09 动手 | 不用 tracing,从零写一个带调用点缓存的最小日志门面 |
小结
- 四个 crate 各对着自己的 tag 读:
tracing-0.1.44(2d55f6fa)、tracing-core-0.1.36(10a9e838)、tracing-attributes-0.1.31(55086231)、tracing-subscriber-0.3.23(54ede4d5)。别的 tag 里的同名目录不是你编译的那份代码。 - crates.io 上只有 0.1;0.2 在
v0.2.x分支,没发布,Subscriber在那里叫Collect,Layer叫Subscribe。2025 年 6 月起 PR 先合进main,之前是先合进master再移植到 0.1。 - 事件主线:
static调用点 →STATIC_MAX_LEVEL→LevelFilter::current()→ 调用点的Interest→sometimes时问enabled→Event::dispatch→event_enabled→ 每层on_event→fmt::Layer写一行。 - span 主线:同样的门 →
new_span分配Id→enter压栈、守卫 drop 出栈 → clone / drop 增减计数 → 归零try_close→ 所有on_close跑完才释放槽。 - 只想会用读 01、03、04、05 的 fmt 和 EnvFilter、常见坑;想读懂实现按顺序读,02 是重点。