tracing 与可观测性

Rust 可观测性实战:tracing 的 span/event 模型与字段结构化、tracing-subscriber 的 Layer 组合与 EnvFilter、tracing-opentelemetry 接入 OTLP、日志与指标追踪的关联(trace_id 透传)、采样与脱敏的生产取舍。

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 生产部署 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「rust」更多文章

  1. 性能剖析与优化
  2. 测试与基准:criterion 与 proptest
  3. Serde 与序列化生态