Rust 的日志生态分两代:log 是一套按等级输出的文本门面,tracing 则把「一段代码的执行上下文」(span)和「某一时刻发生的事」(event)建模成一等公民。异步服务里一次请求会跨多个任务、多个线程甚至多个服务,只有结构化上下文才能把它们串起来。
本文回答三个问题:tracing 的 span 与 event 到底怎么用;subscriber 和 Layer 如何组合出想要的输出;以及怎么把它接到 OpenTelemetry 的 OTLP 管道上,让日志、指标、追踪三者能互相跳转。
从 log 到 tracing
log 只表达「发生了什么」,没有上下文携带能力:
use log::{info, warn};
info!("user {} created order {}", user_id, order_id); // 靠字符串拼接
问题在于:想给这条日志补上 request_id、tenant_id,要么手动拼进字符串(无法结构化检索),要么在每个函数签名里传上下文。tracing 用 span 解决:
use tracing::{info, instrument, span, Level};
#[instrument(skip(pool), fields(user_id = %user.id, tenant = %tenant))]
async fn create_order(pool: &PgPool, user: &User, tenant: String) -> Result<Order> {
info!(items = items.len(), "creating order"); // 自动带上 span 字段
// ...
}
#[instrument] 会在函数进入时创建 span、退出时关闭,期间所有 event 自动携带该 span 的字段。这是它与 log 的本质差别:上下文随调用链隐式传递。
Span 与 Event 的语义
手动创建 span
let span = span!(Level::INFO, "db.query", table = "orders", slow = tracing::field::Empty);
let _guard = span.enter(); // 作用域内 event 归属此 span
// 事后补充字段(声明时用 Empty 占位)
span.record("slow", true);
span.enter() 返回的 guard 基于当前线程,在 .await 前后跨线程调度时会串味。异步代码里应改用 .instrument(span):
use tracing::Instrument;
async fn handle(req: Request) {
let span = span!(Level::INFO, "handle", path = %req.path);
do_work(req).instrument(span).await; // 正确:span 随 future 移动
}
Event 与字段
info!(bytes = n, elapsed_ms = e.as_millis(), "response sent");
warn!(error = %err, retry = attempt, "upstream failed");
debug!(?payload, "raw body"); // ? 用 Debug,% 用 Display
?value 与 %value 是字段值的两种格式约定,等价于在 format! 里用 {:?} 和 {}。字段是结构化的,输出成 JSON 后可以按 elapsed_ms > 500 直接过滤。
字段继承
子 span 会继承父 span 的字段,这让 request_id 只写一次:
let root = info_span!("request", request_id = %uuid::Uuid::new_v4());
async move {
handle_auth().await; // 内部 span 自动带 request_id
handle_query().await;
}.instrument(root).await;
Subscriber 与 Layer 组合
tracing 本身只负责产生事件,输出由 subscriber 决定。tracing-subscriber 的 Layer 是组合单元,可以叠出「控制台彩色输出 + 文件 JSON + 指标计数」三路并行。
use tracing_subscriber::{fmt, prelude::*, EnvFilter};
tracing_subscriber::registry()
.with(EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "info,my_crate=debug,sqlx=warn".into()))
.with(fmt::layer().with_target(true).with_line_number(true))
.with(fmt::layer().json().with_writer(std::io::stderr))
.init();
EnvFilter 的语法是 target=level 逗号分隔,支持 RUST_LOG 环境变量覆盖,无需重新编译即可调级别:
RUST_LOG="info,my_app::db=trace,sqlx=debug" ./server
常见 Layer
| Layer | 作用 |
|---|---|
fmt::layer() | 人类可读的彩色控制台输出 |
fmt::layer().json() | 结构化 JSON,供采集器解析 |
tracing_error::ErrorLayer | 支持 SpanTrace,把错误关联到创建它的 span |
tracing_opentelemetry::layer() | 导出到 OTLP |
tracing_subscriber::fmt::layer().with_span_events() | 输出 span 进入/退出事件 |
生产环境建议控制台保持
info,把debug/trace只写给 JSON 层,避免高流量下控制台成为瓶颈。
接入 OpenTelemetry
把 tracing 的 span 变成真正的分布式追踪,需要 tracing-opentelemetry + OTLP exporter。关键是用 OpenTelemetry 的上下文注入,让 trace_id 跨进程传播。
[dependencies]
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
tracing-opentelemetry = "0.28"
opentelemetry = "0.27"
opentelemetry-otlp = { version = "0.27", features = ["grpc-tonic"] }
opentelemetry_sdk = { version = "0.27", features = ["rt-tokio"] }
use opentelemetry_otlp::WithExportConfig;
use tracing_subscriber::{prelude::*, EnvFilter};
let exporter = opentelemetry_otlp::SpanExporter::builder()
.with_tonic()
.with_endpoint("http://otel-collector:4317")
.build()?;
let tracer = opentelemetry_sdk::trace::TracerProvider::builder()
.with_batch_exporter(exporter, opentelemetry_sdk::runtime::Tokio)
.with_resource(opentelemetry_sdk::Resource::new(vec![
opentelemetry::KeyValue::new("service.name", "order-api"),
opentelemetry::KeyValue::new("deployment.environment", "prod"),
]))
.build()
.tracer("order-api");
tracing_subscriber::registry()
.with(EnvFilter::from_default_env())
.with(tracing_opentelemetry::layer().with_tracer(tracer))
.with(tracing_subscriber::fmt::layer())
.init();
service.name 是必须的资源属性,缺了它后端无法按服务聚合。更多采集端与语义约定细节见 OpenTelemetry 埋点实践
。
跨服务传播
HTTP 入口要从请求头提取上下文,出口要注入:
use opentelemetry::global;
use opentelemetry::propagation::Extractor;
// 入站:从 traceparent 头恢复父 span
let parent = global::get_text_map_propagator(|p| {
p.extract(&HeaderExtractor(req.headers()))
});
let span = tracing::info_span!("http.request", parent = ?parent);
默认传播器是 W3C traceparent/tracestate。只有入站提取、出站注入都做对,一次请求的 trace 才会在服务间连成一条链。
日志、指标、追踪联动
三者的分工
| 信号 | 回答的问题 | Rust 工具 |
|---|---|---|
| 日志 | 发生了什么 | tracing::event! |
| 指标 | 系统整体健康度 | metrics + metrics-exporter-prometheus |
| 追踪 | 请求慢在哪一段 | tracing + OpenTelemetry span |
三者通过 trace_id / span_id 互相关联:日志里带 trace_id 就能从日志跳到追踪;指标的 exemplar 上带 trace_id 就能从指标尖峰跳到那条慢请求。这正是 可观测性三大支柱
的联动方式。
指标埋点
use metrics::{counter, histogram, describe_histogram};
describe_histogram!("http_request_duration_seconds", "请求耗时");
histogram!("http_request_duration_seconds", "method" => "GET")
.record(elapsed.as_secs_f64());
counter!("http_requests_total", "status" => status.to_string()).increment(1);
指标是高基数敏感的:不要把 user_id、request_id 当标签,否则时间序列会爆炸。
在日志里带上 trace_id
OpenTelemetry 层会把当前 span 的 trace_id 注入 fmt 输出。若需手动引用:
let trace_id = tracing::Span::current()
.id()
.map(|id| id.into_u64())
.unwrap_or(0);
生产环境取舍
采样
全量导出追踪的成本很高,通常按比例采样:
use opentelemetry_sdk::trace::Sampler;
let provider = TracerProvider::builder()
.with_sampler(Sampler::ParentBased(Box::new(Sampler::TraceIdRatioBased(0.1))))
.with_batch_exporter(exporter, runtime::Tokio)
.build();
ParentBased 保证「上游采样了我就采样」,避免链路断裂。排障时用「尾部采样」按错误/慢请求条件保留。
脱敏与体积
// 敏感字段不要进日志
#[instrument(skip(password, token))] // 整参跳过
fn login(user: &str, password: &str, token: &str) {}
// 字段级脱敏
info!(email = %mask(&user.email), "signup");
性能
tracing 的事件在无 subscriber 时开销接近零,但字段格式化(%/?)是惰性的,只在有 subscriber 且级别命中时才求值。批量导出用 BatchSpanExporter,避免每条 span 一次网络往返。
小结
log输出文本,tracing输出带上下文的 span + event;异步代码一律用.instrument()而非enter()。- subscriber 用 Layer 组合,
EnvFilter支持运行时调级别,生产环境 JSON 层与人类可读层分离。 - 接 OTLP 必须设置
service.name,并在入站/出站两侧都做上下文传播,trace 才能跨服务串起来。 - 日志、指标、追踪靠 trace_id 互相跳转;指标标签务必防高基数,敏感字段用
skip或脱敏。 - 生产落地时的部署、容器与指标暴露细节,可参考 Rust 生产部署 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。