Rust in Everything
基于 tracing 0.1.44

tracing 解决什么问题

主线位置:两条主线的外面。本页回答为什么要有这两条线:事件主线对应「记一条带字段的日志」,span 主线对应「标出一段时间,让这段时间里的日志知道自己属于谁」。下一页「一条日志穿过 tracing」用一个程序把两条线完整跑一遍。

flowchart TB
  code["库和应用:info! 与 info_span!"] --> facade["tracing:宏、Span、Instrument"]
  facade --> core["tracing-core:Dispatch"]
  core --> sub["某个 Subscriber"]
  sub --> fmtsub["tracing-subscriber 的 fmt"]
  sub --> otel["tracing-opentelemetry"]
  sub --> own["自己写的 Subscriber"]

一句话定位

tracing/src/lib.rs 第 1 行:

A scoped, structured logging and diagnostics system.

「scoped」和「structured」是它比普通日志多出来的两样东西。紧接着的 Overview 一段 说明了为什么需要它们:

In asynchronous systems like Tokio, interpreting traditional log messages can
often be quite challenging. Since individual tasks are multiplexed on the same
thread, associated events and log lines are intermixed making it difficult to
trace the logic flow. `tracing` expands upon logging-style diagnostics by
allowing libraries and applications to record structured events with additional
information about *temporality* and *causality* — unlike a log message, a span
in `tracing` has a beginning and end time, may be entered and exited by the
flow of execution, and may exist within a nested tree of similar spans. In
addition, `tracing` spans are *structured*, with the ability to record typed
data as well as textual messages.

文档的「Core Concepts」把 API 分成三个概念,全卷都围着它们转:

概念文档里的说法在本卷的哪条线上
span「a period of time with a beginning and an end」,执行流可以进入、退出span 主线
event「a moment in time」,可以发生在某个 span 里事件主线
subscriber实现 Subscriber trait,收集 span 和 event;可以用 enabled 过滤两条线的终点

同一节还有一句:「if no currently active subscribers express interest in a given set of metadata by returning true, then the corresponding Span or Event will never be constructed」。没人要的事件连构造都不构造,这件事由几道门完成,下一页逐道走一遍。

交错的任务:log 分不清,span 分得清

先用 log 写一个两个请求并发执行的小程序。load_user 代表调用链深处的函数:它不知道自己在为哪个请求干活。两个请求查的恰好是同一个用户,这在真实服务里很常见。

log = "0.4"
tokio = { version = "=1.54.1", features = ["macros", "rt"] }
use log::{Level, LevelFilter, Log, Metadata, Record, info};

// 一个最小的 log 实现:把每条记录打印成一行
struct Stdout;

impl Log for Stdout {
  fn enabled(&self, metadata: &Metadata) -> bool {
    metadata.level() <= Level::Info
  }

  fn log(&self, record: &Record) {
    if self.enabled(record.metadata()) {
      println!("{:>5} {}: {}", record.level(), record.target(), record.args());
    }
  }

  fn flush(&self) {}
}

static LOGGER: Stdout = Stdout;

// 深处的函数:不知道自己在为哪个请求干活
async fn load_user(user: u32) {
  info!("cache miss");
  tokio::task::yield_now().await;
  info!("query ok, user={user}");
}

async fn handle(user: u32) {
  info!("start");
  load_user(user).await;
  info!("done");
}

#[tokio::main(flavor = "current_thread")]
async fn main() {
  log::set_logger(&LOGGER).unwrap();
  log::set_max_level(LevelFilter::Info);

  let a = tokio::spawn(handle(7));
  let b = tokio::spawn(handle(7));
  a.await.unwrap();
  b.await.unwrap();
}

M2 Max、rustc 1.99.0、--release(log 解析到 0.4.34),跑 3 次输出相同:

 INFO plain_log: start
 INFO plain_log: cache miss
 INFO plain_log: start
 INFO plain_log: cache miss
 INFO plain_log: query ok, user=7
 INFO plain_log: done
 INFO plain_log: query ok, user=7
 INFO plain_log: done

两个任务在同一个线程上交替执行,日志交错在一起。第 5 行的 query ok 属于哪个请求?从文字上看不出来。要分清,只能给每一行都带上请求 id,也就是把 id 当参数一路传进 load_user,以及它调用的每一个函数,包括你改不了的第三方库。

换成 tracing。handle 和 load_user 的函数体不变(user 顺手改成了字段),只在 main 里给每个请求的 future 套一个 span:

tracing = "=0.1.44"
tracing-subscriber = { version = "=0.3.23", features = ["env-filter"] }
tokio = { version = "=1.54.1", features = ["macros", "rt"] }
use tracing::{Instrument, info, info_span};

// 深处的函数照样不知道请求 id;user 改成了字段
async fn load_user(user: u32) {
  info!("cache miss");
  tokio::task::yield_now().await;
  info!(user, "query ok");
}

async fn handle(user: u32) {
  info!("start");
  load_user(user).await;
  info!("done");
}

#[tokio::main(flavor = "current_thread")]
async fn main() {
  tracing_subscriber::fmt()
    .with_env_filter("info")
    .with_ansi(false)
    .without_time()
    .init();

  // 唯一的改动:每个请求的 future 外面套一个 span
  let a = tokio::spawn(handle(7).instrument(info_span!("request", id = 1)));
  let b = tokio::spawn(handle(7).instrument(info_span!("request", id = 2)));
  a.await.unwrap();
  b.await.unwrap();
}

同样跑 3 次,输出相同:

 INFO request{id=1}: with_span: start
 INFO request{id=1}: with_span: cache miss
 INFO request{id=2}: with_span: start
 INFO request{id=2}: with_span: cache miss
 INFO request{id=2}: with_span: query ok user=7
 INFO request{id=2}: with_span: done
 INFO request{id=1}: with_span: query ok user=7
 INFO request{id=1}: with_span: done

交错的顺序和 log 版一样,但每一行前面多了 request{id=…}:。第 5 行其实属于第二个请求,按文字顺序去猜会猜错。哪个任务先恢复由 tokio 的调度器决定(见 current_thread 调度器),和 tracing 无关。

load_user 没有多接收任何参数,它的日志却知道了自己属于哪个请求,靠的是 Instrumented 的 poll:

impl<T: Future> Future for Instrumented<T> {
    type Output = T::Output;

    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
        let (span, inner) = self.project().span_and_inner_pin_mut();
        let _enter = span.enter();
        inner.poll(cx)
    }
}

每次被 poll 先进入 span,poll 返回时守卫 _enter 被 drop,退出 span。任务在 .await 处让出线程时已经退出了自己的 span,另一个任务被 poll 时进入的是它自己的 span。subscriber 记着「这个线程当前在哪个 span 里」(tracing-subscriber 的 Registry 用一个线程局部的栈),fmt 格式化事件时取出这个栈,把 span 的名字和字段印在前面。poll 本身见 tokio 卷的 poll 与 wake;Instrumented 的细节在 Instrument 与异步,线程局部的 span 栈在 Registry。

💡TIP
打个比方:工牌

每个请求有一块工牌,写着「request id=1」(Span 和它的字段)。工牌挂在请求的文件夹上(Instrumented 里的 span 字段),不挂在工人身上。哪个工人(线程)拿起这份文件夹干活,就先把工牌戴上(poll 开头的 enter),放下时摘掉(_enter drop 时的 exit)。工人往日志本上写字时,旁边的书记员(subscriber)看一眼他胸前的工牌,把工牌上的字抄在这一行前面(fmt 打印 request{id=1}:)。

比方到这里为止:真实的 span 还有开始和结束(创建和关闭),可以一层套一层组成树;而且「看一眼工牌」不是 tracing 自动做的,是 subscriber 自己记录 enter / exit 的结果。一个不理会 enter 的 subscriber,日志里就不会有 span。

跨线程也串得起来

工牌挂在文件夹上,所以换一个工人接手也不会丢。把运行时换成 4 个工作线程的多线程运行时,yield_now 换成 sleep(5ms),fmt 打开线程 id:

use std::time::Duration;
use tracing::{Instrument, info, info_span};

async fn load_user(user: u32) {
  info!("cache miss");
  tokio::time::sleep(Duration::from_millis(5)).await;
  info!(user, "query ok");
}

async fn handle(user: u32) {
  info!("start");
  load_user(user).await;
  info!("done");
}

#[tokio::main(flavor = "multi_thread", worker_threads = 4)]
async fn main() {
  tracing_subscriber::fmt()
    .with_env_filter("info")
    .with_ansi(false)
    .without_time()
    .with_thread_ids(true)
    .init();

  let tasks: Vec<_> = (1..=2)
    .map(|id| tokio::spawn(handle(7).instrument(info_span!("request", id))))
    .collect();
  for t in tasks {
    t.await.unwrap();
  }
}

tokio 的 feature 要加上 rt-multi-thread 和 time。某一次的输出:

 INFO ThreadId(03) request{id=2}: across_threads: start
 INFO ThreadId(04) request{id=1}: across_threads: start
 INFO ThreadId(03) request{id=2}: across_threads: cache miss
 INFO ThreadId(04) request{id=1}: across_threads: cache miss
 INFO ThreadId(02) request{id=1}: across_threads: query ok user=7
 INFO ThreadId(02) request{id=1}: across_threads: done
 INFO ThreadId(04) request{id=2}: across_threads: query ok user=7
 INFO ThreadId(04) request{id=2}: across_threads: done

请求 1 在线程 04 上开始,sleep 醒来后到了线程 02;请求 2 从 03 到了 04。行前的 request{id=…} 没有跟着线程变。这个程序跑了 10 次,线程 id 和行的先后每次不同,但每一次两个请求都在 sleep 之后换了线程,span 前缀每次都对。任务为什么会换线程见 tokio 卷的 工作窃取与空闲。

字段是结构化的

log 版写的是 info!("query ok, user={user}"),user 被格式化进消息字符串,logger 拿到的是一个 fmt::Arguments。tracing 版写的是 info!(user, "query ok"),user 是一个字段:

  • 字段名在编译期就确定了,由宏写进调用点的 'static 元数据(Metadata 里的 FieldSet),见 Metadata、Level 与 LevelFilter。
  • 值带着类型交给 subscriber。u32 在 tracing-core 的 impl_values! 里归到 record_u64,subscriber 通过 Visit trait 一个字段一个字段地收到 (字段, 值),见 字段:Field、Value 与 Visit。
  • 消息本身也是一个字段,名字叫 message。

上面输出里的 query ok user=7 只是 fmt 的默认呈现方式。换成别的 subscriber(JSON 格式、OpenTelemetry),它们通过同一个 Visit 接口拿到名字和带类型的值,不用再从字符串里把 user=7 解析出来。

📝NOTE
没有蠢问题

问: log 0.4 也有结构化日志了(文档的「Structured logging」一节:开启 kv feature 就能给记录附加键值对),那 tracing 多出来的到底是什么?

答: 多出来的是上下文。kv 让一条记录可以带字段,但字段还得在打这条日志的地方写出来,load_user 不知道请求 id,照样写不出来。log 的 Log trait 只有 enabled、log、flush 三个方法,logger 没有任何途径知道「现在执行的是哪个请求」。tracing 的 Subscriber 多了 new_span、enter、exit 这一组方法,subscriber 能记住当前在哪个 span 里,再把 span 的字段附到这段时间里发生的每个事件上。

门面和实现分开

这一点 tracing 和 log 一样。两者文档里的「In libraries」几乎是同一句话。tracing(第 542 行):

Libraries should link only to the `tracing` crate, and use the provided
macros to record whatever information will be useful to downstream
consumers.

log 0.4.34:

Libraries should link only to the `log` crate, and use the provided
macros to log whatever information will be useful to downstream consumers.

tracing 的「In executables」接着说「This library does not contain any Subscriber implementations」:tracing 只有宏、Span 和分发,真正把日志写到哪里,由应用选一个 subscriber 装上(最常用的是 tracing-subscriber)。文档还专门警告库不要调用 set_global_default,否则应用自己再设时会冲突。Subscriber trait 和分发机制放在单独的 tracing-core 里,原因见 源码地图。

两个门面逐项对比(log 一栏全部出自 log 0.4.34 的 crate 文档和源码):

项log 0.4.34tracing 0.1.44
实现方要实现的 traitLog:enabled、log、flushtracing-core 的 Subscriber:判断、span 生命周期、事件三组方法,见 Subscriber trait
安装set_logger / set_boxed_logger,文档:「The logging system may only be initialized once.」set_global_default 也只能一次;另有 with_default / set_default,只对当前线程生效,见 Dispatch
没装实现时「noop」实现,文档:「just an integer load, comparison and jump」全局最大级别 MAX_LEVEL 初值是 OFF,宏读一次原子变量、比较一次就跳过
运行期最大级别set_max_level,文档说默认是 Off,要由 logger 设置由所有 subscriber 的 max_level_hint 算出,装上 subscriber 时自动更新
编译期关掉级别max_level_* / release_max_level_* feature,决定 STATIC_MAX_LEVEL同名的 feature,决定 tracing 自己的 STATIC_MAX_LEVEL
结构化字段kv feature字段是宏语法的一部分
一段时间没有对应的概念span

两边能互通:tracing 的 log feature 让 span 和事件在没有 tracing subscriber 时也发出 log 记录;反过来 tracing-log 的 LogTracer 把依赖里的 log 记录转成 tracing 事件,tracing-subscriber 的 init()(默认开着 tracing-log feature)会顺手把它装上。见 与 log 互通。

slog 与 OpenTelemetry

slog 的文档和源码不在本卷核对的范围内,这里不比较两者的设计。能查到的只有两件事:tracing 仓库的第一个提交「sketch core types」(2018-09-19)之后,紧接着的几个提交(2018-09-21)是「start on fake Slog example」「working slog-style example」,那个例子至今还在仓库里,叫 sloggish,README 说它「mimics the output of slog-term's Compact formatter」;log 0.4.34 的文档把 slog-stdlog 列为一个适配器。

OpenTelemetry 和 tracing 的关系是一句话:tracing 本身不导出任何东西,第三方的 tracing-opentelemetry 是又一个 subscriber,crate 文档的「Related Crates」写的是「provides a subscriber for emitting traces to OpenTelemetry-compatible distributed tracing systems」(第 721 行)。

小结

  • tracing 是「scoped, structured」的日志门面:事件带有名字和类型的字段,span 表示一段有开始和结束的时间。
  • 两个任务在同一线程上交错时,log 的输出分不清哪行属于哪个请求;给 future 套上 .instrument(span) 后,每行都带上 request{id=…},连不知道请求 id 的深层函数也一样。
  • 靠的是 Instrumented::poll 每次 poll 都进入 span、返回时退出,subscriber 按线程记住当前 span;span 跟着 future 走,任务换线程也不丢(10 次运行都换了线程)。
  • 门面和实现分开,和 log 相同:库只依赖 tracing,应用装 subscriber,全局只能装一次;tracing 还多了按线程覆盖的 set_default。
  • log 的 kv 也能带字段,但 Log trait 没有 enter / exit,logger 无从知道当前在哪个请求里。
  • OpenTelemetry 通过 tracing-opentelemetry 这个 subscriber 接入;slog 本卷不做比较。

💬 关联讨论

发起讨论
加载中...