Appearance
第18章 wasm-tracing 与可观测性
"Observability is not about logs, metrics, or traces — it's about asking questions without shipping new code." — Charity Majors
18.1 WASM 可观测性的根本挑战
原生 Rust 应用拥有丰富的可观测性工具链:strace 追踪系统调用,perf 分析 CPU 热点,/proc/pid/status 查看内存使用,tokio-console 监控异步任务,Valgrind 检测内存泄漏,eBPF 做内核态动态追踪。这些工具依赖操作系统提供的内核接口——ptrace、procfs、perf_event、signal handler——WASM 沙箱把这一切全部切断。
WASM 模块的可观测性挑战不仅是"工具缺失",而是"可观测性根基被抽走"。原生环境下,即使应用本身没有任何可观测性代码,外部工具仍然可以 attach 进程、读取 /proc、拦截系统调用。WASM 环境中,宿主看不到模块内部状态,模块也无法主动访问宿主的观测接口——这是一个双向的信息隔离。这种隔离是 WASM 安全模型的核心设计——正是沙箱阻止了模块访问宿主资源,才保证了 WASM 的安全性。但安全的代价是可观测性的丧失。
更具体地说,原生环境中可观测性的"免费"能力在 WASM 中全部消失。在 Linux 上,strace 通过 ptrace 系统调用拦截进程的每一次系统调用,不需要被观测进程做任何配合。perf 通过 perf_event_open 读取 CPU 性能计数器,同样不需要被观测进程的参与。/proc/pid/maps 直接暴露进程的内存映射。这些机制全部依赖操作系统内核——而 WASM 没有操作系统内核,它运行在宿主提供的一个纯净的虚拟指令集中。
这意味着 WASM 的可观测性必须从内部主动构建——模块自己决定输出什么信息,宿主决定如何收集和展示。本章从 Rust 侧的 tracing 基础设施开始,逐步扩展到宿主侧的 metrics API、性能分析、分布式追踪和错误报告,最终给出一份可观测性检查清单。
WASM 可观测性的建设分为两个方向:从内向外的方案(模块主动输出信息给宿主)和从外向内的方案(宿主从外部观测模块行为)。前者包括 tracing 日志、结构化日志、自定义性能计数器;后者包括 Wasmtime metrics API、Chrome DevTools profiling、内存监控。两个方向互补——从内向外提供语义丰富的信息("正在处理图像"),从外向内提供精确的度量数据("执行了 3.2ms")。
18.2 Rust 侧:tracing + 自定义 subscriber
Rust 的 tracing crate 是可观测性的基础设施——它在编译时插入 span 和 event,运行时由 subscriber 处理。tracing 的核心抽象是三层结构:
- Span:代表一个操作的时间范围,可以嵌套。例如
process_image函数的执行期间就是一个 span。嵌套的 span 形成树状结构——外层 span 是内层 span 的 parent。 - Event:代表一个时间点的离散事件。例如
info!("Starting image processing")。Event 附加在当前活跃的 span 上,继承了 span 的上下文信息。 - Subscriber:消费 span 和 event 的后端。默认是输出到 stderr——WASM 中不可用。
在 WASM 中,tracing 的默认 subscriber(输出到 stderr)不可用,因为 WASM 没有标准错误流。WASI Preview 1 的 fd_write 只在有 WASI 支持时可用,浏览器环境根本没有标准 I/O 的概念。需要自定义 subscriber 把日志导出到宿主。
console_log 方案
最简单的方案:把 tracing 事件转发到 JS 的 console.log:
rust
use tracing::{info, warn, error, instrument};
use tracing_subscriber::layer::SubscriberExt;
use tracing_wasm::WASMLayer;
fn init_tracing() {
tracing_subscriber::registry()
.with(WASMLayer::new(Default::default()))
.init();
}
#[instrument(name = "process_image", skip(data))]
fn process_image(data: &[u8]) -> Vec<u8> {
info!(len = data.len(), "Starting image processing");
// ... 处理 ...
info!("Image processing complete");
result
}tracing-wasm crate 提供的 WASMLayer 把每个 tracing event 转换为 console.log/console.warn/console.error 调用——在浏览器 DevTools 中可以直接看到。span 的进入和退出分别对应 console.group 和 console.groupEnd,形成层级化的日志输出。在 DevTools 中你会看到可折叠的日志组——点击展开可以看到函数内部的所有事件,这在调试复杂调用链时非常有用。
这个方案的开销需要量化:每个 info!() 调用约 200-500ns(包括 span 创建、字段格式化、JS console 调用)。在热路径上不建议使用——只在函数入口/出口/错误处记录。对于 4K 图像处理的场景(约 5ms),如果每个像素记录一次日志,开销将超过计算本身——这是典型的"观测开销超过被观测对象"的反模式。
自定义 subscriber 控制开销
更精细的做法是自定义 Layer,按级别和 span 名过滤——这在不修改业务代码的前提下控制观测开销:
rust
use tracing_subscriber::Layer;
use tracing::Level;
fn init_tracing() {
let filter = tracing_subscriber::filter::Targets::new()
.with_target("image_processor", Level::INFO)
.with_target("crypto_module", Level::WARN)
.with_default(Level::ERROR);
tracing_subscriber::registry()
.with(WASMLayer::new(Default::default()).with_filter(filter))
.init();
}这样 image_processor 模块只输出 INFO 及以上级别的日志,crypto_module 只输出 WARN 及以上——密钥派生等高频操作不会产生日志洪流。这种过滤在编译时通过 tracing 的 level 宏已经做了第一次筛选(info! 在编译为 ERROR 级别时完全不生成代码),Targets filter 做的是运行时的第二次筛选——确保即使宏生成了代码,不需要的模块也不会输出。
日志级别的策略设计
在实际项目中,日志级别不是简单的"开发用 DEBUG,生产用 ERROR"——需要一个更精细的策略:
| 级别 | 何时使用 | WASM 场景示例 | 典型频率 |
|---|---|---|---|
| ERROR | 必须立即处理的问题 | 加密失败、内存耗尽、数据损坏 | 罕见(每天 < 10 次) |
| WARN | 可能导致问题但不致命 | 输入接近上限、降级到后备方案 | 偶尔(每小时 < 10 次) |
| INFO | 关键业务事件 | 函数调用入口/出口、请求开始/结束 | 按请求(每个请求 1-5 次) |
| DEBUG | 调试时的诊断信息 | 中间计算结果、状态转换 | 仅开发环境 |
| TRACE | 极细粒度的执行追踪 | 循环内部状态、内存指针值 | 仅定位特定 bug 时 |
关键原则:WASM 中 INFO 及以下级别的日志在热路径上应该完全不存在——不是"运行时过滤掉",而是"编译时不生成"。tracing 的 level 宏在设置 max_level = "info" 时,所有 debug! 和 trace! 调用在编译时被完全消除——零运行时开销。
18.3 结构化日志:从文本到数据
console.log 的文本日志不利于机器解析。更好的方案是通过 wasm-bindgen 把结构化日志传给 JS——这在《Serde 元编程》一书中对序列化的讨论同样适用:数据只有结构化之后才能被下游系统消费。
文本日志的问题不仅是"难以解析"——更根本的问题是"信息丢失"。当 info!("Processing image: width={}, height={}", w, h) 输出 "Processing image: width=3840, height=2160" 时,宽度和高度的信息被埋入文本中——下游系统需要正则匹配才能提取。如果字段名变更(width → w),所有解析逻辑都会断裂。结构化日志保留了字段的名值对关系,下游系统可以按字段名精确查询。
结构化日志方案
rust
use wasm_bindgen::prelude::*;
use serde::Serialize;
use serde_json::Value;
#[derive(Serialize)]
struct LogEntry {
level: String,
target: String,
message: String,
timestamp: f64,
span_name: Option<String>,
fields: Value,
}
#[wasm_bindgen]
extern "C" {
#[wasm_bindgen(js_namespace = window, js_name = "__log_collector")]
fn collect_log(entry: &JsValue);
}
fn emit_structured_log(
level: &str,
target: &str,
message: &str,
span_name: Option<&str>,
fields: Value,
) {
let entry = LogEntry {
level: level.to_string(),
target: target.to_string(),
message: message.to_string(),
timestamp: js_sys::Date::now(),
span_name: span_name.map(|s| s.to_string()),
fields,
};
// serde_json 在 WASM 中会增加约 20KB 体积
// 如果体积敏感,可替换为 serde-json-core (no_std)
let js_entry = JsValue::from_serde(&entry).unwrap();
collect_log(&js_entry);
}JS 侧的 __log_collector 可以做任意后端对接:
javascript
// JS 侧配置
const logBuffer = [];
window.__log_collector = function(entry) {
// 1. 开发环境:输出到 DevTools
if (process.env.NODE_ENV === 'development') {
console.log(`[${entry.level}] ${entry.target}: ${entry.message}`, entry.fields);
}
// 2. 生产环境:批量发送到日志服务
logBuffer.push(entry);
if (logBuffer.length >= 50) {
flushLogs(); // 发送到 Grafana Loki / OpenTelemetry Collector
}
};批量发送是生产环境的关键优化——每个日志条目单独发送 HTTP 请求的开销不可接受。50 条一批、每 5 秒超时发送一次,是实测中较好的平衡点。
把 tracing 集成为结构化日志的 subscriber
更优雅的方案不是手动调用 emit_structured_log,而是实现一个自定义 Layer,自动把 tracing 事件转换为结构化日志——业务代码完全不感知后端的存在:
rust
use tracing_subscriber::Layer;
use tracing::{Event, Subscriber};
use tracing_subscriber::layer::Context;
struct StructuredWasmLayer;
impl<S: Subscriber> Layer<S> for StructuredWasmLayer {
fn on_event(&self, event: &Event, ctx: Context<S>) {
let mut fields = serde_json::Map::new();
event.record(&mut FieldVisitor(&mut fields));
let span_name = ctx.current_span().name().map(|s| s.to_string());
let metadata = event.metadata();
let entry = LogEntry {
level: metadata.level().to_string(),
target: metadata.target().to_string(),
message: fields.remove("message")
.and_then(|v| v.as_str().map(|s| s.to_string()))
.unwrap_or_default(),
timestamp: js_sys::Date::now(),
span_name,
fields: Value::Object(fields),
};
let js_entry = JsValue::from_serde(&entry).unwrap();
collect_log(&js_entry);
}
}
struct FieldVisitor<'a>(&'a mut serde_json::Map<String, Value>);
impl tracing::field::Visit for FieldVisitor<'_> {
fn record_str(&mut self, field: &tracing::field::Field, value: &str) {
self.0.insert(field.name().to_string(), Value::String(value.to_string()));
}
fn record_u64(&mut self, field: &tracing::field::Field, value: u64) {
self.0.insert(field.name().to_string(), Value::Number(value.into()));
}
fn record_f64(&mut self, field: &tracing::field::Field, value: f64) {
self.0.insert(field.name().to_string(), serde_json::Number::from_f64(value)
.map(Value::Number).unwrap_or(Value::Null));
}
fn record_bool(&mut self, field: &tracing::field::Field, value: bool) {
self.0.insert(field.name().to_string(), Value::Bool(value));
}
// 其他类型省略...
}这个 subscriber 的优势在于:业务代码只需使用标准的 tracing 宏(info!、warn!、error!),日志的后端传输方式完全由 subscriber 决定——切换后端不需要改动业务代码。这种分离在《Serde 元编程》一书讨论的序列化抽象中也有体现:数据模型和序列化格式的解耦,让同一份数据可以适配不同的传输层。
结构化日志的体积权衡
serde + serde_json 在 WASM 中会增加约 20KB 体积。如果项目体积敏感,可以用 serde-json-core(基于 no_std,约 5KB)替代。serde-json-core 的局限是不支持动态类型(serde_json::Value),需要预先定义所有日志结构体——这对于日志场景通常是可接受的,因为日志格式在设计阶段就可以确定。
另一个体积优化方向是避免在每次日志调用时分配 String——用 heapless::String 或预分配的缓冲区替代动态字符串。这在嵌入式和 no_std 场景中常见,但在浏览器 WASM 中收益有限——浏览器的 JS 引擎对字符串分配有高效优化。
18.4 宿主侧:Wasmtime 的 metrics API
WASM 模块内部的观测只是故事的一半——宿主侧也能收集模块运行时的宏观数据,且不需要修改模块代码。这是"从外向内"的观测方向。
Wasmtime Engine Metrics
Wasmtime 28+ 提供了 Engine::metrics() API——宿主可以查询 WASM 模块的运行时统计。这些指标在 Wasmtime 的 Cranelift 编译器和运行时中收集,不需要 WASM 模块做任何配合:
rust
use wasmtime::{Engine, Config, Metric};
let mut config = Config::new();
config.wasm_metrics(true); // 显式启用 metrics 收集
let engine = Engine::new(&config)?;
// ... 实例化和执行 WASM 模块 ...
// 查询指标
let metrics = engine.metrics();
for (name, value) in metrics.iter() {
match name {
Metric::CompilationTime => {
println!("Compilation: {}ms", value.as_duration()?.as_millis())
}
Metric::InstancesCreated => {
println!("Instances: {}", value.as_u64())
}
Metric::MemoryGrowthCount => {
println!("Memory grows: {}", value.as_u64())
}
_ => {}
}
}可用的指标及其诊断意义:
| 指标 | 类型 | 说明 | 诊断意义 |
|---|---|---|---|
CompilationTime | Duration | 编译 WASM 到机器码的总时间 | 编译耗时过长需考虑模块拆分或缓存 |
InstancesCreated | Counter | 创建的实例数量 | 实例过多可能需要池化 |
MemoryGrowthCount | Counter | memory.grow 调用次数 | 异常增长说明内存使用模式有问题 |
MemoryGrowthTime | Duration | memory.grow 的总耗时 | 频繁 grow 导致性能抖动 |
FuncCallCount | Counter | 函数调用次数 | 调用频率异常可能是死循环 |
TrapCount | Counter | trap 发生次数 | 非零即表示模块有运行时错误 |
每个指标的解读需要结合场景。例如,MemoryGrowthCount 为 10 在一个长时间运行的边缘计算组件中完全正常——内存逐步增长是正常行为。但如果一个请求处理组件在单次请求中触发了 10 次 memory.grow,说明内存分配策略可能有问题——应该考虑预分配更大的初始内存。
构建 metrics dashboard
这些指标在宿主侧收集,不需要修改 WASM 模块代码。适合构建监控面板——以下是宿主侧导出 Prometheus 格式的示例:
rust
use std::sync::atomic::{AtomicU64, Ordering};
struct WasmMetrics {
compilation_time_ms: AtomicU64,
instances_created: AtomicU64,
memory_grows: AtomicU64,
trap_count: AtomicU64,
}
impl WasmMetrics {
fn snapshot(&self, engine: &Engine) -> String {
let metrics = engine.metrics();
// 解析并格式化为 Prometheus exposition format
format!(
"# HELP wasm_compilation_ms Total compilation time\n\
# TYPE wasm_compilation_ms counter\n\
wasm_compilation_ms {}\n\
# HELP wasm_memory_grows Memory growth operations\n\
# TYPE wasm_memory_grows counter\n\
wasm_memory_grows {}\n\
# HELP wasm_traps Total trap count\n\
# TYPE wasm_traps counter\n\
wasm_traps {}\n",
self.compilation_time_ms.load(Ordering::Relaxed),
self.memory_grows.load(Ordering::Relaxed),
self.trap_count.load(Ordering::Relaxed),
)
}
}在服务器端 WASM 场景中(第 17 章讨论的边缘计算和插件系统),这些 metrics 可以和 Rust 后端框架(如 axum,参考《axum Web 框架》一书)的 /metrics 端点结合,暴露给 Prometheus + Grafana 监控栈。在 Grafana 面板中,可以设置告警规则——例如 TrapCount > 0 触发 PagerDuty 告警,MemoryGrowthCount 增长率 > 10 次/分钟 触发 Slack 通知。
metrics 开销分析
启用 wasm_metrics(true) 本身有性能开销——Wasmtime 在每个关键操作点插入计数器更新。实测数据:启用 metrics 后,函数调用开销增加约 5-10ns/次(从约 10ns 增加到约 15-20ns)。对于单次函数调用这个开销可以忽略,但对于高频调用的场景(每秒百万次),5ns 的增量累积为 5ms/秒——约 0.5% 的额外 CPU 开销。
如果需要更精确的指标(如每次内存分配的大小分布),可以考虑在 Wasmtime 的 Store 级别设置自定义的 epoch-based 中断——在每个 epoch 边界采样一次当前状态,而不是在每次操作时更新计数器。这种抽样式 metrics 的开销更低,但数据精度也相应降低。
18.5 性能分析:WASM 的 profiling 方案
Chrome DevTools Performance 面板
Chrome 的 Performance 面板可以录制 WASM 函数的调用栈和耗时。Chrome 123+ 支持从 WASM DWARF 调试信息生成 source map——这让 Rust 源码级别的性能分析成为可能。
编译配置:
bash
# 编译时带调试信息
cargo build --target wasm32-unknown-unknown --profile profiling
# wasm-bindgen 保留名称和调试信息
wasm-bindgen --target web --keep-debug --profiling target/.../my_lib.wasm对应的 Cargo.toml profile 定义:
toml
[profile.profiling]
inherits = "release"
opt-level = 3 # 保留 release 级优化——否则 profiling 结果不反映真实性能
debug_info = true # 保留 DWARF 调试信息
strip = false # 不 strip 符号
lto = true
codegen-units = 1在 Performance 面板中,WASM 函数会显示为 Rust 函数名(而非 wasm[0x1234])。DWARF 信息让 Chrome 能把 WASM 地址映射回 Rust 源文件和行号——这比之前用十六进制地址标识有质的提升。你可以直接在 Performance 面板中看到"图像处理的热点是 grayscale_simd 函数的第 42 行",而不是"热点在 wasm[0x3f7a]"。
DWARF 信息的代价:.wasm 体积增加 30-50%。profiling 构建不用于生产——只在性能调查时使用。一个常见的工程实践是:在 CI 中保留 profiling 构建产物,当生产环境出现性能退化时,用 profiling 构建复现问题。
自定义性能计数器
对于需要精细性能分析的模块,可以在 Rust 代码中插入性能计数器,并集成到浏览器的 Performance Timeline:
rust
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
extern "C" {
#[wasm_bindgen(js_namespace = performance)]
fn mark(name: &str);
#[wasm_bindgen(js_namespace = performance)]
fn measure(name: &str, start_mark: &str, end_mark: &str);
}
#[wasm_bindgen]
pub fn process_with_timing(data: &[u8]) -> Vec<u8> {
performance::mark("process_start");
let result = do_process(data);
performance::mark("process_end");
performance::measure("process_duration", "process_start", "process_end");
result
}performance.mark() 和 performance.measure() 把计时数据写入浏览器的 Performance Timeline——和其他 JS 性能数据(如 fetch 请求、DOM 渲染)统一查看。这在第 16 章浏览器集成的场景中特别有用——可以在同一张时间线上看到 WASM 计算和 JS UI 渲染的交互关系。例如,你可能会发现"WASM 图像处理在 5ms 内完成,但 JS 侧的 Canvas 绘制花了 20ms"——瓶颈不在 WASM,而在 JS 侧的 DOM 操作。
采样 profiling
当需要找出热点函数但不能在每个函数插入计时代码时,采样 profiling 是更实用的方案。其原理是通过定时器回调中断执行,记录当前的调用栈。采样频率 1000Hz 意味着每 1ms 记录一次——足够识别 >5ms 的热点函数。
WASM 中采样 profiling 的实现比原生环境更困难。原生环境中,perf 可以直接读取 CPU 的指令指针寄存器(RIP)来获取当前执行的指令地址——这是一个硬件级别的操作,对被观测进程几乎零干扰。WASM 中没有这样的硬件支持——必须通过 JS 的定时器机制间接实现:
rust
use wasm_bindgen::prelude::*;
use std::cell::RefCell;
thread_local! {
static PROFILER: RefCell<SampleProfiler> = RefCell::new(SampleProfiler::new());
}
struct SampleProfiler {
samples: Vec<StackSample>,
sampling: bool,
}
struct StackSample {
timestamp: f64,
wasm_stack: Vec<String>,
}
#[wasm_bindgen]
pub fn start_profiling(sample_rate_hz: u32) {
PROFILER.with(|p| {
let mut profiler = p.borrow_mut();
profiler.sampling = true;
profiler.samples.clear();
});
// 通过 JS 的 setInterval 触发采样
start_sampler_interval(sample_rate_hz);
}
#[wasm_bindgen]
pub fn stop_profiling() -> JsValue {
PROFILER.with(|p| {
let profiler = p.borrow_mut();
profiler.sampling = false;
// 序列化采样数据供 JS 端分析
JsValue::from_serde(&profiler.samples).unwrap()
})
}需要注意的是,WASM 中获取调用栈的成本较高——需要调用 WebAssembly.Module.customSections() 或使用 Error.stack 来捕获栈信息。在高频采样下这本身就会影响性能,因此采样频率不宜超过 1000Hz。实际上,对于大多数 Web 应用,100Hz(每 10ms 采样一次)已经足够识别热点——只有在对极短操作(<10ms)做微优化时才需要更高频率。
18.6 分布式追踪:WASM + OpenTelemetry
在服务器端 WASM 场景中(第 17 章的边缘计算和插件系统),一个请求可能经过多个组件——API 网关、认证组件、业务逻辑组件、数据访问组件。需要分布式追踪串联调用链。分布式追踪的核心概念是 trace context——一个包含 trace-id 和 span-id 的上下文对象,在组件之间传播,让所有组件产生的 span 都关联到同一个 trace。
浏览器侧:WASM → JS → OpenTelemetry
在浏览器环境中,WASM 模块可以通过 wasm-bindgen 把 span 数据传给 JS 侧的 OpenTelemetry SDK:
rust
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
extern "C" {
#[wasm_bindgen(js_namespace = window, js_name = "__otel")]
fn start_span(name: &str, trace_id: &str, parent_span_id: &str) -> JsValue;
#[wasm_bindgen(js_namespace = window, js_name = "__otel")]
fn end_span(span_id: &str);
#[wasm_bindgen(js_namespace = window, js_name = "__otel")]
fn add_span_attribute(span_id: &str, key: &str, value: &str);
}
struct WasmSpan {
span_id: String,
}
impl WasmSpan {
fn new(name: &str, parent: Option<&WasmSpan>) -> Self {
let trace_id = get_current_trace_id();
let parent_id = parent.map(|p| p.span_id.as_str()).unwrap_or("");
let span_js = start_span(name, &trace_id, parent_id);
let span_id = span_js.as_string().unwrap_or_default();
WasmSpan { span_id }
}
fn set_attribute(&self, key: &str, value: &str) {
add_span_attribute(&self.span_id, key, value);
}
fn end(self) {
end_span(&self.span_id);
}
}JS 侧用 @opentelemetry/sdk-trace-web 处理这些 span 数据:
javascript
import { WebTracerProvider } from '@opentelemetry/sdk-trace-web';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
const provider = new WebTracerProvider({ exporter: new OTLPTraceExporter() });
provider.register();
const activeSpans = new Map();
window.__otel = {
startSpan(name, traceId, parentId) {
const tracer = provider.getTracer('wasm-module');
const span = tracer.startSpan(name, {
links: parentId ? [{ context: { traceId, spanId: parentId } }] : []
});
activeSpans.set(span.spanContext().spanId, span);
return span.spanContext().spanId;
},
endSpan(spanId) {
const span = activeSpans.get(spanId);
if (span) { span.end(); activeSpans.delete(spanId); }
},
addSpanAttribute(spanId, key, value) {
const span = activeSpans.get(spanId);
if (span) span.setAttribute(key, value);
}
};服务器侧:跨组件传播 trace context
WASI Preview 2 目前没有标准的 trace context 传播机制。实践中通过自定义的 WIT 接口传递 trace context——这与第 14 章组件模型的 WIT 接口设计模式一脉相承:
wit
interface tracing {
/// 一个分布式追踪的 span
resource span {
/// 为 span 添加键值对属性
set-attribute: func(key: string, value: string) -> void;
/// 记录一个错误事件
record-error: func(msg: string) -> void;
/// 结束 span
end: func() -> void;
}
/// 获取当前的活跃 span
get-current-span: func() -> span;
/// 创建子 span
start-span: func(name: string, parent: option<span>) -> span;
/// 从外部注入 trace context(用于跨组件传播)
inject-context: func(trace-id: string, span-id: string) -> span;
}
world traced-component {
import tracing;
/// 业务接口
export process: func(input: list<u8>) -> list<u8>;
}宿主实现这个接口,把 span 数据转发到 OpenTelemetry SDK:
rust
use opentelemetry::trace::{Tracer, Span, SpanKind, Status};
use opentelemetry::KeyValue;
// 宿主侧实现
struct OtelSpan {
inner: opentelemetry::sdk::trace::Span,
tracer: opentelemetry::sdk::trace::Tracer,
}
impl GuestSpan for OtelSpan {
fn set_attribute(&self, key: String, value: String) {
self.inner.set_attribute(KeyValue::new(key, value));
}
fn record_error(&self, msg: String) {
self.inner.set_status(Status::Error(msg.into()));
}
fn end(&self) {
self.inner.end();
}
}
impl Tracing for ComponentHost {
fn start_span(name: String, parent: Option<ResourceSpan>) -> ResourceSpan {
let builder = self.tracer.span_builder(name);
let span = if let Some(parent_span) = parent {
builder.start_with_context(&self.tracer, &parent_span.inner.context())
} else {
builder.start(&self.tracer)
};
ResourceSpan { inner: span, tracer: self.tracer.clone() }
}
fn inject_context(trace_id: String, span_id: String) -> ResourceSpan {
let context = opentelemetry::Context::new()
.with_remote_trace_context(trace_id, span_id);
let span = self.tracer.start_with_context("wasm-component", &context);
ResourceSpan { inner: span, tracer: self.tracer.clone() }
}
}WASM 组件调用 wasi:tracing/start-span 创建 span——宿主自动把 span context 传播给下游组件。这实现了跨 WASM 组件的分布式追踪。
这种设计的关键在于:WASM 组件本身不需要知道 OpenTelemetry 的存在——它只调用 WIT 定义的 tracing 接口。具体的后端实现(OpenTelemetry、Jaeger、Zipkin)完全由宿主决定。这是能力安全哲学的体现——组件只需声明"我需要追踪能力",宿主决定如何满足。这与《tokio 异步运行时》一书中 tracing 集成的思路一致——运行时提供 tracing 基础设施,业务代码只负责发出事件。
trace context 传播的 W3C 标准
W3C 定义了 Trace Context 标准协议(RFC 0043),规定了 HTTP 头 traceparent 和 tracestate 的格式。在 WASM 组件模型中,trace context 的传播不通过 HTTP 头,而是通过 WIT 接口参数。上面的 inject-context 函数接收 trace-id 和 span-id——这是 W3C traceparent 头的拆解版本。宿主从上游请求的 traceparent 头解析出 trace-id 和 span-id,通过 inject-context 注入到第一个 WASM 组件的 tracing 上下文中——后续组件通过 start-span 的 parent 参数自动继承。
18.7 错误报告:让 trap 不再是黑盒
trap 的处理
WASM trap(越界访问、除零等)在宿主侧表现为 wasmtime::Trap:
rust
match instance.call_async(&mut store, "process", &args).await {
Ok(result) => { /* 正常处理 */ }
Err(e) if e.is_trap() => {
let trap = e.downcast::<wasmtime::Trap>()?;
match trap {
Trap::StackOverflow => {
report_error(
"Stack overflow — possible infinite recursion",
ErrorSeverity::Critical,
);
}
Trap::MemoryOutOfBounds => {
report_error(
"Memory out of bounds — possible buffer overflow or allocation failure",
ErrorSeverity::Critical,
);
let mem_size = instance
.get_memory(&store, "memory")
.map(|m| m.data_size(&store))
.unwrap_or(0);
metrics.memory_oob_count.fetch_add(1, Ordering::Relaxed);
}
Trap::IntegerDivisionByZero => {
report_error("Division by zero in WASM", ErrorSeverity::Warning);
}
_ => report_error(&format!("Trap: {:?}", trap), ErrorSeverity::Unknown),
}
}
Err(e) => return Err(e.into()),
}Trap 的信息很有限——只有 trap 的类型,没有具体地址和调用栈。Trap 告诉你"发生了越界访问",但不告诉你"哪行代码越界了"——这就像一个医生只告诉你"病人不舒服",但不告诉你是头疼还是肚子疼。
Wasmtime 通过 Config::debug_info(true) 支持 DWARF 调试信息,可以还原 Rust 层面的调用栈——但需要在编译时生成调试信息,且 .wasm 体积会增加 30-50%。在生产环境中,可以通过条件编译在 debug/profiling 构建中启用 DWARF,在 release 构建中禁用。
panic 的可观测性
Rust 的 panic! 在 WASM 中默认调用 abort() 触发 trap。trap 信息只是 "unreachable"——没有 panic 消息、没有调用栈。这在生产环境中是不可接受的——用户报告"白屏",开发者只能看到"unreachable executed",完全无法定位问题。
更可观测的方案是用 std::panic::catch_unwind 捕获 panic:
rust
use wasm_bindgen::prelude::*;
/// 安全的 WASM 入口函数——所有 panic 都被捕获并转为 JS 错误
#[wasm_bindgen]
pub fn safe_process(data: &[u8]) -> Result<Vec<u8>, JsValue> {
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
do_process(data)
}))
.map_err(|e| {
let msg = if let Some(s) = e.downcast_ref::<String>() {
s.clone()
} else if let Some(s) = e.downcast_ref::<&str>() {
s.to_string()
} else {
"unknown panic".to_string()
};
// 记录 panic 到结构化日志
emit_structured_log(
"ERROR",
module_path!(),
&format!("PANIC: {}", msg),
None,
serde_json::json!({
"panic_message": msg,
"input_len": data.len(),
}),
);
JsValue::from_str(&format!("PANIC: {}", msg))
})
}但 catch_unwind 有局限:它在 panic = "abort" 模式下不起作用(整个进程直接终止)。而 WASM 生产构建通常设置 panic = "abort" 以减小体积。因此需要在 Cargo.toml 中做 profile 区分:
toml
[profile.dev]
panic = "unwind" # 开发时用 unwind——catch_unwind 可以工作
[profile.release]
panic = "abort" # 发布时用 abort——更小的体积,catch_unwind 不可用
[profile.profiling]
panic = "unwind" # 性能分析时也用 unwind——便于捕获 panic对于生产环境的 panic 观测,更可靠的方式是设置自定义 panic hook——它在 panic = "abort" 模式下仍然生效:
rust
use std::panic;
#[wasm_bindgen(start)]
pub fn init() {
panic::set_hook(Box::new(|info| {
let msg = format!("PANIC: {}", info);
// 通过 JS console 输出
web_sys::console::error_1(&JsValue::from_str(&msg));
// 通过结构化日志输出
emit_structured_log("FATAL", "panic_hook", &msg, None, serde_json::json!({
"location": info.location().map(|l| format!("{}:{}", l.file(), l.line())).unwrap_or_default(),
}));
}));
}set_hook 在 panic = "abort" 模式下仍然生效——它会在 abort 之前执行。这样即使在 release 构建中,panic 消息也能被记录下来。关键区别是:catch_unwind 让 panic 变成可恢复的错误(返回 Result),而 set_hook 只是在 panic 不可恢复之前记录信息——之后仍然会 abort。对于大多数场景,set_hook + 结构化日志是更好的选择,因为它不影响 panic=abort 的体积优势。
错误分类和告警策略
在生产环境中,不同的错误类型需要不同的响应策略:
| 错误类型 | 严重性 | 响应策略 | 示例 |
|---|---|---|---|
| 预期错误(业务逻辑) | Info | 记录日志,正常返回 | "输入为空"、"格式不支持" |
| 非预期错误(内部 bug) | Error | 记录详细上下文,返回错误 | "数组越界"、"断言失败" |
| Panic(不可恢复) | Critical | 立即告警,记录调用栈 | "unwrap on None"、"index out of bounds" |
| Trap(运行时异常) | Critical | 立即告警,分析 trap 类型 | "StackOverflow"、"MemoryOutOfBounds" |
18.8 内存泄漏检测
WASM 的线性内存由宿主分配,模块通过 memory.grow 请求扩展。内存泄漏的表现是 memory.buffer.byteLength 持续增长——最终达到 WASM 内存上限(默认 4GB,32 位地址空间)导致 memory.grow 失败。
WASM 内存泄漏与原生 Rust 内存泄漏有本质区别。原生 Rust 的内存泄漏通常是"忘记 drop 某个值"——Valgrind 或 ASan 可以精确追踪每次分配和释放,定位泄漏的分配点。WASM 中没有这样的工具——memory.grow 是操作系统级别的内存分配,WASM 模块内部的分配(通过 dlmalloc 或 wee_alloc)对宿主不可见。宿主只能看到线性内存的总大小在增长,无法知道是哪个数据结构在泄漏。
JS 侧监控内存增长
javascript
class WasmMemoryMonitor {
constructor(wasmMemory, options = {}) {
this.memory = wasmMemory;
this.checkInterval = options.checkInterval || 5000; // 5 秒检查一次
this.warningThreshold = options.warningThreshold || 0.8; // 80% 使用率警告
this.growthRateThreshold = options.growthRateThreshold || 0.1; // 10% 增长率警告
this.history = [];
}
start() {
this.intervalId = setInterval(() => this.check(), this.checkInterval);
}
check() {
const currentSize = this.memory.buffer.byteLength;
const maxSize = 4 * 1024 * 1024 * 1024; // 4GB (32-bit)
const usageRatio = currentSize / maxSize;
this.history.push({ timestamp: Date.now(), size: currentSize });
// 计算增长率(过去 1 分钟)
const oneMinuteAgo = this.history.filter(
h => h.timestamp > Date.now() - 60000
);
if (oneMinuteAgo.length > 1) {
const growthRate = (currentSize - oneMinuteAgo[0].size) / oneMinuteAgo[0].size;
if (growthRate > this.growthRateThreshold) {
console.warn(
`WASM memory growing rapidly: ${(growthRate * 100).toFixed(1)}% in last minute`,
`Current: ${(currentSize / 1024 / 1024).toFixed(1)}MB`
);
}
}
if (usageRatio > this.warningThreshold) {
console.error(
`WASM memory usage critical: ${(usageRatio * 100).toFixed(1)}%`,
`Current: ${(currentSize / 1024 / 1024).toFixed(1)}MB / 4096MB`
);
}
}
stop() {
clearInterval(this.intervalId);
}
}
// 使用
const monitor = new WasmMemoryMonitor(wasm.memory, {
warningThreshold: 0.6, // 60% 警告
growthRateThreshold: 0.05, // 5% 增长率警告
});
monitor.start();需要特别注意的一个陷阱:当 WASM 线性内存增长时,WebAssembly.Memory 的 buffer 属性会被替换为一个新的 ArrayBuffer——旧的 ArrayBuffer 变成 "detached" 状态,任何对它的访问都会抛出 TypeError。这意味着 JS 侧缓存的 Uint8Array 视图在 memory.grow 后会失效——必须在每次 memory.grow 后重新创建视图:
javascript
// 错误:缓存的视图在 memory.grow 后失效
const cachedView = new Uint8Array(wasm.memory.buffer);
wasm.some_function_that_grows_memory(); // 可能触发 memory.grow
cachedView[0]; // TypeError: detached ArrayBuffer
// 正确:每次访问前检查 buffer 是否被替换
function getView() {
if (currentBuffer !== wasm.memory.buffer) {
currentBuffer = wasm.memory.buffer;
currentView = new Uint8Array(currentBuffer);
}
return currentView;
}Rust 侧的分配追踪
更精细的内存追踪可以在 Rust 侧实现——通过自定义全局分配器记录每次分配和释放。这种方法可以精确回答"哪个分配点在泄漏"的问题:
rust
use std::alloc::{GlobalAlloc, Layout, System};
use std::sync::atomic::{AtomicUsize, Ordering};
struct TrackingAllocator;
static ALLOCATED: AtomicUsize = AtomicUsize::new(0);
static ALLOCATION_COUNT: AtomicUsize = AtomicUsize::new(0);
unsafe impl GlobalAlloc for TrackingAllocator {
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
let ptr = System.alloc(layout);
if !ptr.is_null() {
ALLOCATED.fetch_add(layout.size(), Ordering::Relaxed);
ALLOCATION_COUNT.fetch_add(1, Ordering::Relaxed);
}
ptr
}
unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
System.dealloc(ptr, layout);
ALLOCATED.fetch_sub(layout.size(), Ordering::Relaxed);
}
}
#[global_allocator]
static GLOBAL: TrackingAllocator = TrackingAllocator;
// 导出当前内存使用量给 JS
#[wasm_bindgen]
pub fn get_memory_stats() -> JsValue {
JsValue::from_serde(&serde_json::json!({
"allocated_bytes": ALLOCATED.load(Ordering::Relaxed),
"allocation_count": ALLOCATION_COUNT.load(Ordering::Relaxed),
})).unwrap()
}这个方案的注意事项:自定义分配器本身有性能开销(每次分配/释放都有原子操作),不适合在生产热路径使用。只在调试内存泄漏时启用。一个更精细的方案是只在特定时间段启用追踪——通过一个 AtomicBool 开关控制是否记录分配信息:
rust
static TRACKING_ENABLED: AtomicBool = AtomicBool::new(false);
unsafe impl GlobalAlloc for TrackingAllocator {
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
let ptr = System.alloc(layout);
if !ptr.is_null() && TRACKING_ENABLED.load(Ordering::Relaxed) {
ALLOCATED.fetch_add(layout.size(), Ordering::Relaxed);
}
ptr
}
// ...
}
#[wasm_bindgen]
pub fn set_memory_tracking(enabled: bool) {
TRACKING_ENABLED.store(enabled, Ordering::Relaxed);
if !enabled {
// 重置计数器
ALLOCATED.store(0, Ordering::Relaxed);
ALLOCATION_COUNT.store(0, Ordering::Relaxed);
}
}这样 JS 侧可以在检测到内存增长异常时,临时启用追踪来定位泄漏,定位完成后关闭追踪恢复性能。
18.9 可观测性架构总览
18.10 SLO 定义与告警设计
可观测性的最终目的不是"看到数据"——而是在出问题时被及时叫醒。这需要明确的 SLO(Service Level Objective)和精确的告警规则,否则要么漏报(错过故障)要么误报(值班人员被告警淹没)。
18.10.1 WASM 工作负载的 SLO 维度
每条 SLI 都对应一个 SLO(例如"实例化成功率月度 > 99.9%"),SLO 决定 error budget——超出 budget 触发告警和回滚。
18.10.2 告警规则的分级
不是所有指标异常都该叫醒值班人员。三级告警体系:
| 级别 | 触发条件 | 通知渠道 | 响应 SLA |
|---|---|---|---|
| P0 紧急 | 服务不可用,影响所有用户 | 电话 + Slack + PagerDuty | 5 分钟 |
| P1 严重 | SLO 违反,影响部分用户 | Slack + 邮件 | 30 分钟 |
| P2 关注 | 指标异常但未违反 SLO | 邮件 | 工作时间内 |
WASM 特有的告警规则示例:
yaml
# Prometheus alerting
groups:
- name: wasm-alerts
rules:
- alert: WasmInstantiateFailureRate
expr: rate(wasm_instantiate_failures_total[5m]) / rate(wasm_instantiate_total[5m]) > 0.01
for: 2m
labels:
severity: P1
annotations:
summary: "WASM 实例化失败率 > 1%"
runbook: "https://wiki/runbooks/wasm-instantiate-failure"
- alert: WasmTrapRate
expr: rate(wasm_trap_total[5m]) > 0.1
for: 1m
labels:
severity: P0
annotations:
summary: "WASM trap 率 > 0.1/s — 用户大面积报错"
- alert: WasmMemoryGrowing
expr: max_over_time(wasm_linear_memory_bytes[1h]) > 0.8 * 4 * 1024 * 1024 * 1024
for: 10m
labels:
severity: P2
annotations:
summary: "WASM 线性内存使用 > 3.2GB(接近 4GB 上限)"18.10.3 减少告警噪音
值班人员的精力有限——告警噪音直接降低系统可靠性。三个去噪手段:
手段一:基于 SLO 的告警。不要对每个指标设阈值——对 error budget 设阈值。如果月度 SLO 是 99.9%,错误预算是 0.1% × 30 天 × 86400 秒 = 2592 秒。当剩余预算 < 30% 时再告警,而不是任何瞬时错误率突破阈值都告警。
手段二:相关性抑制。如果上游服务故障导致下游 WASM 调用失败,应该只告警上游——下游的告警自动抑制:
yaml
inhibit_rules:
- source_match: { service: 'upstream-api', severity: 'P0' }
target_match: { service: 'wasm-processor' }
equal: ['cluster']手段三:智能聚合。100 个 Pod 同时报同一个错误应该聚合为 1 条告警,附上"影响范围"。Alertmanager 的 group_by 配置:
yaml
route:
group_by: ['alertname', 'service']
group_wait: 30s
group_interval: 5m18.10.4 SLO 看板:从指标到决策
实战的 SLO 看板必须回答三个问题:
- 现在状态健康吗?(实时 SLI)
- 错误预算还剩多少?(窗口内的累计违反)
- 趋势在恶化吗?(与上周/上月对比)
错误预算是工程纪律——超预算就停止发布。这条规则比任何技术方案都重要:它强制工程团队为可靠性买单,而不是不断加新功能直到系统崩溃。
18.11 浏览器端 WASM 的真实用户监控(RUM)
服务器端 WASM 的可观测性可以用传统工具(Prometheus、OpenTelemetry)。浏览器端 WASM 必须用 RUM——从真实用户的浏览器收集性能数据。开发机的数据再漂亮也代表不了 99 元 Android 手机用户的真实体验。
18.11.1 浏览器 WASM 必须监控的指标
| 类别 | 指标 | 收集方式 |
|---|---|---|
| 加载 | WASM 下载耗时 | performance.getEntriesByType('resource') |
| 加载 | WASM 编译耗时 | instantiate() 前后 performance.now() 差 |
| 加载 | 实例化耗时 | 同上 |
| 加载 | 加载失败率 | catch 块统计 |
| 执行 | 首次调用延迟 | 记录第一次导出函数调用 |
| 执行 | P50/P95/P99 调用延迟 | 每次调用打点 |
| 资源 | 线性内存峰值 | wasm.memory.buffer.byteLength 采样 |
| 资源 | memory.grow 频率 | hook 拦截 |
| 错误 | trap / panic 总数 | error boundary 捕获 |
18.11.2 RUM 实现样板
javascript
class WasmRUM {
constructor(endpoint) {
this.endpoint = endpoint;
this.metrics = {
load: {},
calls: [],
errors: [],
memory: { peak: 0, growCount: 0 },
};
}
async loadWasm(url) {
const t0 = performance.now();
try {
const response = await fetch(url);
const t1 = performance.now();
const { instance } = await WebAssembly.instantiateStreaming(response);
const t2 = performance.now();
this.metrics.load = {
downloadMs: t1 - t0,
compileInstantiateMs: t2 - t1,
totalMs: t2 - t0,
userAgent: navigator.userAgent,
connectionType: navigator.connection?.effectiveType,
};
return instance;
} catch (err) {
this.metrics.errors.push({ phase: 'load', error: err.message });
throw err;
}
}
instrumentCall(fn, fnName) {
return (...args) => {
const t0 = performance.now();
try {
const result = fn(...args);
this.metrics.calls.push({ fn: fnName, ms: performance.now() - t0 });
return result;
} catch (err) {
this.metrics.errors.push({ fn: fnName, error: err.message });
throw err;
}
};
}
flush() {
if (this.metrics.calls.length === 0 && this.metrics.errors.length === 0) return;
navigator.sendBeacon(this.endpoint, JSON.stringify(this.metrics));
this.metrics.calls = [];
this.metrics.errors = [];
}
}
// 使用
const rum = new WasmRUM('/api/wasm-rum');
const instance = await rum.loadWasm('/my-lib.wasm');
const fastFn = rum.instrumentCall(instance.exports.process, 'process');
window.addEventListener('beforeunload', () => rum.flush());
setInterval(() => rum.flush(), 30000);navigator.sendBeacon 是关键——它能在页面卸载时发送数据,不会被浏览器丢弃(普通 fetch 在 unload 时常被取消)。
18.11.3 采样与隐私
每个用户每分钟生产几百条数据——10 万 DAU 一天就是几亿条。全量采集成本极高。
采样策略:
| 数据类型 | 采样率 | 理由 |
|---|---|---|
| 加载指标 | 100% | 频次低,价值高 |
| 调用延迟 | 1-10% | 量大,统计意义足够 |
| 错误日志 | 100% | 错误必须捕获 |
| 慢调用(> P99) | 100% | 性能问题诊断的关键证据 |
隐私合规:RUM 数据不能包含 PII(个人身份信息)。userAgent 已经接近 PII(可指纹识别)——欧盟 GDPR 下,发送 userAgent 到自有服务器需要用户同意。
合规做法:把 userAgent 在客户端处理,只发送类别(mobile-android-low-end / desktop-modern):
javascript
function classifyDevice() {
const ua = navigator.userAgent;
const memory = navigator.deviceMemory || 4; // GB
if (/iPhone|iPad/.test(ua)) return memory < 4 ? 'mobile-ios-old' : 'mobile-ios-modern';
if (/Android/.test(ua)) return memory < 4 ? 'mobile-android-low' : 'mobile-android-mid';
return 'desktop';
}18.11.4 数据的下游使用
RUM 数据汇总后驱动三类决策:
性能回归检测特别关键:每次发版前对比新旧版本的 P95,如果 P95 恶化 > 10%,自动阻止 100% 切流。这是从生产事故中学到的教训——开发机性能正常的版本,可能因为某个 polyfill 或 bundle 体积变化在低端设备上崩溃。
18.12 可观测性数据的成本控制与采样
可观测性最容易被忽视的工程问题:数据生产成本。一个高流量 WASM 服务每天产出几 TB 日志、几亿条 metrics、上亿条 trace span——全量采集的存储和传输成本可能比业务计算本身还高。理性的成本控制需要明确的采样策略。
18.12.1 三类数据的成本特征
实际成本数据(Datadog / Grafana Cloud / Honeycomb 等典型 SaaS):
| 数据 | 单价 | 100 RPS 服务月成本 |
|---|---|---|
| 全量 logs | $0.40/GB | ~$3500(每请求 5KB log) |
| 全量 metrics | $5/series/月 | ~$200(高基数标签) |
| 全量 traces | $0.80/百万 span | ~$2500(每请求 10 span) |
不做采样的成本几乎是业务运营成本量级——必须采样。
18.12.2 Logs 的采样策略
策略一:按级别采样——错误日志 100%,info 日志 1%:
rust
fn should_log(level: Level, sample_rate: f64) -> bool {
if level >= Level::ERROR { return true; } // 错误必采
if level >= Level::WARN { return rand::random::<f64>() < 0.5; } // warn 50%
rand::random::<f64>() < sample_rate // info 配置采样率
}策略二:按业务维度采样——VIP 用户 100%,普通用户 1%:
rust
fn sample_for_user(user_tier: UserTier, base_rate: f64) -> bool {
match user_tier {
UserTier::Vip => true,
UserTier::Premium => rand::random::<f64>() < (base_rate * 10.0).min(1.0),
UserTier::Free => rand::random::<f64>() < base_rate,
}
}策略三:按异常模式采样——慢请求、错误请求 100%,正常请求 1%:
rust
fn should_sample(elapsed_ms: u64, status: u16, base_rate: f64) -> bool {
if status >= 500 { return true; }
if elapsed_ms > 1000 { return true; }
rand::random::<f64>() < base_rate
}这三种策略可以组合——错误 + VIP + 慢请求三层 OR 关系,其余 1% 采样。生产环境的典型配置:
18.12.3 Metrics 的基数控制
Metrics 成本主要来自高基数标签(cardinality)——每个唯一的 label 组合算一个 series。如果 metric 有 5 个 label 各 100 个值,最大 100^5 = 100 亿 series。
反模式:
rust
// 反模式:把用户 ID 作为 label
metrics::counter!("api_requests_total", "user_id" => user_id.to_string()).increment(1);
// 100 万用户 = 100 万 series,存储成本爆炸正确做法:
rust
// 把高基数的维度移到 logs/traces,metrics 只用低基数
metrics::counter!("api_requests_total",
"endpoint" => endpoint, // 低基数(< 100)
"status" => status.to_string(), // 低基数(< 10)
).increment(1);
// user_id 放到 logs 中
tracing::info!(user_id = %user_id, endpoint = %endpoint, "request");工程纪律:metrics label 设计 review 必须经过——每个 label 的预期值数量必须明确,> 100 的 label 一律拒绝。
18.12.4 Traces 的头部 vs 尾部采样
头部采样(head-based):在 trace 开始时决定是否采集——简单但盲目。
尾部采样(tail-based):等 trace 完成后根据其属性决定——可以"只采样错误 trace",但需要先存所有 span 在内存中:
OpenTelemetry Collector 的 tailsamplingprocessor 实现了尾部采样——把所有 span 在 collector 缓冲,trace 完整后做决策。代价:collector 必须缓存几秒的所有 trace 数据,内存压力大。
18.12.5 数据保留期分级
不是所有数据都需要长期保留:
| 数据类别 | 保留期 | 用途 |
|---|---|---|
| 错误日志 | 90 天 | 合规审计 |
| INFO 日志(采样) | 14 天 | 排错 |
| Metrics 原始分辨率 | 7 天 | 实时调试 |
| Metrics 1 分钟聚合 | 90 天 | 趋势分析 |
| Metrics 1 小时聚合 | 1 年 | 容量规划 |
| Traces(采样) | 7 天 | 性能诊断 |
| 错误 traces | 30 天 | 排错 |
存储分级让长期成本可控。具体配置在 Loki/Tempo/Mimir 中通过 retention policy 实现。
18.12.6 成本监控本身
可观测性数据本身需要可观测——监控成本指标:
rust
metrics::gauge!("observability_storage_bytes",
"type" => "logs"
).set(get_logs_storage_size());
metrics::gauge!("observability_ingest_rate_per_second",
"type" => "traces"
).set(get_traces_ingest_rate());设置告警:日志摄入量月增长 > 30% 必须 review。否则一个失控的 debug 日志循环可能让月账单翻倍。
18.12.7 ROI 评估清单
部署可观测性数据时,过这个 ROI 清单:
每个月做一次 review——把"可能有用但从未被查的数据"识别出来,提高其采样率或缩短保留期。这套清单能把可观测性月账单控制在合理范围内(通常占总基础设施成本的 5-10%)。
18.13 合规与隐私:可观测性数据的边界
可观测性数据天然包含敏感信息——用户 ID、IP、查询内容、错误堆栈中的内存数据。处理不当会触犯 GDPR、HIPAA、SOC 2 等合规要求。生产 WASM 部署的可观测性必须从设计开始考虑数据边界。
18.13.1 数据敏感性分类
GDPR 的关键要求:
- 数据最小化:只收集业务必需的数据
- 明确同意:采集 PII 必须用户授权
- 数据保留期限:超期必须删除
- 跨境数据传输:欧盟数据不能未经审查传到美国
18.13.2 WASM 可观测性的常见敏感字段
| 字段 | 敏感性 | 处理建议 |
|---|---|---|
| WASM 函数参数 | 视业务 | 必须脱敏(见下) |
| 错误堆栈 | 含敏感数据风险 | 过滤 / hash |
| 用户 ID | 伪 PII(GDPR) | 哈希 + 盐化 |
| Session ID | 伪 PII | 短期保留即可 |
| IP 地址 | 伪 PII(GDPR) | 截断到 /24 子网 |
| User Agent | 伪 PII | 分类后丢弃原值 |
| URL 查询参数 | 视参数 | 白名单字段 |
| Cookie 内容 | 高敏感 | 完全不记录 |
| Authorization 头 | 极敏感 | 完全不记录 |
WASM 项目特有的敏感数据:错误堆栈中可能含线性内存的内容——一个 panic 报错可能泄漏正在处理的用户数据。必须做主动过滤。
18.13.3 数据脱敏与匿名化
实战手段:
rust
// 反模式:直接日志原值
tracing::info!(user_id = %user.id, "request");
// 输出:user_id=alice@example.com
// 推荐:哈希 + 盐
fn hash_pii(value: &str, salt: &str) -> String {
use sha2::{Sha256, Digest};
let mut hasher = Sha256::new();
hasher.update(salt.as_bytes());
hasher.update(value.as_bytes());
format!("{:x}", hasher.finalize())[..16].to_string()
}
tracing::info!(user_hash = %hash_pii(&user.id, SALT), "request");
// 输出:user_hash=a3f2b1c8d4e7f1a2 (无法反推)IP 地址截断:
rust
fn anonymize_ip(ip: &str) -> String {
if let Ok(addr) = ip.parse::<std::net::IpAddr>() {
match addr {
std::net::IpAddr::V4(v4) => {
let octets = v4.octets();
format!("{}.{}.{}.0", octets[0], octets[1], octets[2])
}
std::net::IpAddr::V6(v6) => {
// /48 截断
let segments = v6.segments();
format!("{:x}:{:x}:{:x}::", segments[0], segments[1], segments[2])
}
}
} else {
"unknown".to_string()
}
}18.13.4 错误堆栈的过滤
WASM panic 时,console_error_panic_hook 输出完整堆栈——可能含变量值。生产环境必须过滤:
rust
use std::panic;
fn install_filtered_panic_hook() {
panic::set_hook(Box::new(|info| {
let location = info.location().map(|l| format!("{}:{}", l.file(), l.line())).unwrap_or_default();
let msg = info.payload().downcast_ref::<&str>().unwrap_or(&"unknown");
// 只发位置和 panic 类型,不发参数值
web_sys::console::error_1(&format!("WASM panic at {location}: {msg}").into());
// 发到错误监控(仅元数据)
report_error(&PanicReport {
location: location.to_string(),
msg_class: classify_panic(msg), // 分类,不传原值
timestamp: get_timestamp(),
});
}));
}classify_panic 把 panic 信息映射到预定义类别(OOM / OutOfBounds / DivByZero / Other)——避免把变量值传到外部。
18.13.5 数据保留期与删除
GDPR 要求"被遗忘的权利"——用户请求删除时必须能彻底清理:
工程实现的关键:所有可观测性数据必须用一致的用户 hash 标识——这样删除时可以一次性查找所有相关记录。如果不同系统用不同 ID,删除会有遗漏。
18.13.6 跨境数据传输
欧盟用户数据不能未审查传到美国——这影响 SaaS 监控的选型:
| SaaS | 欧盟合规 |
|---|---|
| Datadog | 提供欧盟区域 |
| Grafana Cloud | 提供欧盟区域 |
| New Relic | 部分欧盟支持 |
| Honeycomb | 主要美国部署 |
如果业务有欧盟用户,必须选欧盟区域部署的服务——不能图便宜用美国区域。
18.13.7 合规审查 checklist
每条都对应一项法规要求——遗漏任何一条都可能触发监管处罚(GDPR 罚款上限 2000 万欧元或全球营收 4%)。把这套审查嵌入项目立项和上线流程,让合规成为默认而非事后补救。
18.14 WASM 事故响应 Runbook
可观测性的最终目的是支持事故响应——出问题时能在最短时间内定位、缓解、修复。这套 runbook 是"半夜被 paged 时该怎么办"的标准答案,适用于生产 WASM 服务。
18.14.1 事故响应的标准流程
每阶段有清晰目标:
- 确认(< 5 分钟):是误报还是真问题?
- 缓解(< 30 分钟):用户体验先恢复
- 诊断(30 分钟 - 2 小时):找到具体原因
- 修复(视严重性):写代码修
- 复盘(事后 1 周内):写 postmortem
18.14.2 WASM 特有的事故类型
18.14.3 事故场景一:实例化失败率激增
症状:监控显示 wasm_instantiate_failures_total 突然飙升。
响应步骤:
诊断关键:WASM 模块本身往往是好的——问题在加载链路(CSP/CORS/CDN/网络)。这是 WASM 与传统服务的关键差异。
18.14.4 事故场景二:trap 率突增
症状:监控显示 wasm_trap_total 速率上升。
响应步骤:
缓解优先:先回滚到没问题的版本,再慢慢诊断。生产事故不允许"边查边修"。
18.14.5 事故场景三:内存持续增长
症状:wasm_linear_memory_bytes 缓慢但稳定增长,最终触发 OOM。
响应步骤:
WASM 内存泄漏诊断比原生难——没有 valgrind。但 §11.14 的监控基础设施(包装 malloc/free)能定位到具体调用栈。
18.14.6 事故场景四:性能回归
症状:新版本部署后 P95 翻倍。
响应步骤:
性能回归最快的缓解永远是回滚——99% 的"性能 bug"短期不可能现场修复。
18.14.7 事故场景五:跨边界数据错误
症状:用户报错"参数错误"或 WASM 收到意外数据。
响应步骤:
最常见原因:JS 端使用了过期的 .d.ts,调用时传了 WASM 不再支持的格式。
18.14.8 Runbook 模板
每个生产 WASM 服务应有自己的 runbook。模板要素:
把 runbook 写成 markdown 进 wiki 或代码仓库——值班人员能在凌晨 3 点照着做。这套准备工作让生产事故的恢复时间从小时级降到分钟级。
18.14.9 事故复盘的工程价值
每次事故都是免费的学习机会——但只有写出复盘并产生改进项才能兑现。生产纪律:P0 事故必须复盘,P1 事故选择性复盘,复盘的改进项必须有 owner 和 ddl。
18.14.10 把 Runbook 嵌入工作流
Runbook 不是"写了就好"——它必须活的、被使用的文档。每次事故、每次新告警、每个新成员加入都是 runbook 演进的机会。这是把"个人英雄式响应"转为"团队系统化响应"的关键。
18.15 多租户场景的可观测性隔离
SaaS、FaaS、插件平台等多租户 WASM 场景,可观测性数据必须按租户隔离——一个租户不能看到其他租户的日志/指标。这是合规要求也是工程挑战。
18.15.1 多租户隔离的层级
每层都有失败模式:
- 采集:忘记打 tenant_id 标签 → 数据无法分聚
- 存储:共享存储未做隔离 → 跨租户数据泄漏
- 查询:权限错配 → 租户 A 看到租户 B 的数据
18.15.2 数据采集的隔离实现
rust
// 包装所有 metrics,自动加 tenant_id
struct TenantMetrics {
tenant_id: String,
}
impl TenantMetrics {
fn record_call(&self, func: &str, duration: Duration) {
metrics::histogram!(
"wasm_call_duration_seconds",
"tenant" => self.tenant_id.clone(),
"function" => func.to_string(),
).record(duration.as_secs_f64());
}
}
// 类似的 logging
fn log_with_tenant(tenant: &str, message: &str) {
tracing::info!(tenant_id = %tenant, "{}", message);
}工程纪律:任何业务代码不能直接调 metrics/logging——必须经过包装层强制注入 tenant_id。
18.15.3 存储层的隔离方案
| 方案 | 成本 | 隔离强度 | 适用 |
|---|---|---|---|
| 完全隔离 | 高(N×) | 最强 | 监管严格场景 |
| 逻辑隔离 | 低 | 依赖查询权限 | 大多数 SaaS |
| 分组隔离 | 中 | 中 | 大客户优先 |
90% 的 SaaS 选逻辑隔离——成本最低,配合严格的查询权限控制能达到合规要求。
18.15.4 查询权限的实现
rust
// 查询接口必须验证 tenant
async fn query_metrics(
user: &User,
query: &str,
tenant_id: &str,
) -> Result<Vec<Metric>, Error> {
// 1. 验证用户有权访问该租户
if !user.can_access_tenant(tenant_id) {
return Err(Error::AccessDenied);
}
// 2. 强制注入 tenant 过滤条件
let filtered_query = format!("{} AND tenant=\"{}\"", query, tenant_id);
// 3. 执行查询
prometheus::query(&filtered_query).await
}关键:永远不让用户提交的 query 直接执行——必须由后端注入 tenant 过滤。否则用户能用 PromQL 查到其他租户数据。
18.15.5 跨租户全局视图
平台运营者需要看跨租户的全局指标——但要小心隔离边界:
管理员看板应该:
- 显示总量、平均、分位数等聚合数据
- 不显示个别租户的具体业务(如 SQL 查询内容)
- 紧急告警可以显示具体租户,但有审计日志
18.15.6 多租户性能问题诊断
实战:当告警触发时,先看是个别租户还是全平台——决定调查方向。这套分层诊断让响应效率提升显著。
18.15.7 资源隔离的可观测性
多租户除数据隔离,还需监控资源使用隔离:
rust
metrics::gauge!("wasm_tenant_memory_bytes",
"tenant" => tenant_id,
).set(memory_used);
metrics::counter!("wasm_tenant_fuel_consumed",
"tenant" => tenant_id,
).increment(fuel_consumed);
metrics::counter!("wasm_tenant_calls_total",
"tenant" => tenant_id,
).increment(1);监控指标:
- 每租户的内存使用(防止单租户耗尽)
- 每租户的 CPU 时间(fuel)
- 每租户的调用次数(防 abuse)
18.15.8 配额与监控的协同
监控不只是"看"——还要驱动配额执行。这种"监控+控制"的反馈环让多租户平台稳定。
18.15.9 合规与租户审计
每个租户应该能看到自己的可观测性数据:
这些是 SaaS 平台的基础合规要求——必须从一开始就设计进去,不能后期补救。
18.15.10 工程实战清单
每条都对应过去的事故教训——一旦多租户隔离出问题(一个租户看到另一个的数据),客户信任难以恢复。把这套清单嵌入平台设计的每个阶段。
18.16 WASM 可观测性的成熟度模型
可观测性不是"有/无"——而是有不同成熟度。这一节定义 5 级成熟度模型,让团队能客观评估自己的可观测性水平,规划下一步投入。
18.16.1 5 级成熟度框架
每级有明确特征——团队可以对照评估自己的位置。
18.16.2 第 0 级:盲目
许多 WASM 项目的初始状态——开发时能跑就上线,没有任何监控。出问题靠用户反馈。
特征:
- 无错误追踪
- 无性能监控
- 无用户行为数据
- 排查问题靠用户描述
风险:生产事故影响范围未知——损失评估困难。
18.16.3 第 1 级:基础
最低生产可接受水平——出错能感知,能基本定位。
特征:
- 错误堆栈被捕获
- 几个关键指标有监控
- 严重错误触发告警
典型工具:Sentry / Bugsnag / 简单 Prometheus
18.16.4 第 2 级:可观测
中等团队的常态——能看到系统的多维状态。
特征:
- Prometheus + Grafana
- 结构化 JSON 日志
- 真实用户监控数据
- 多个看板
生产价值:能在分钟级定位问题,但仍依赖人的经验。
18.16.5 第 3 级:主动
成熟团队的目标——可观测性成为系统设计的一部分。
特征:
- OpenTelemetry 全链路追踪
- 错误预算驱动决策
- 完整 runbook 和演练
- 性能预算嵌入 CI
生产价值:能在用户感知前发现问题——预防式而非反应式。
18.16.6 第 4 级:智能
少数顶级公司达到——可观测性变成自主系统。
特征:
- ML 模型识别异常模式
- 故障自动定位(比人快 10x)
- 容量需求预测
- 部分场景自动修复
典型工具:Datadog Watchdog / New Relic AI
18.16.7 评估自己的级别
最直观的判定——上次事故你是怎么发现的?
18.16.8 升级路径
每级升级有典型时间——团队可以规划投入。注意:跨级跳进通常失败("我们 0 级直接上 AI"不现实)。
18.16.9 不同业务的合理目标
不是所有业务都需要第 4 级——内部工具达到第 1 级就够了。明确目标避免过度投入。
18.16.10 升级 ROI 分析
| 升级 | 投入 | 收益 |
|---|---|---|
| 0 → 1 | 1 周 | 减少 50% 事故响应时间 |
| 1 → 2 | 1 月 | 减少 30% 事故频率 |
| 2 → 3 | 3 月 | 减少 60% 事故影响 |
| 3 → 4 | 1 年+ | 减少 80% 事故 + 自动化 |
ROI 在第 2-3 级最高——大多数项目应以此为目标。第 4 级仅在规模足够大(百亿级请求)时投入划算。
18.16.11 可观测性的工程文化
可观测性最终是文化问题——技术工具只是手段。把可观测性纳入团队的价值观,让它成为日常工作的一部分而非事后补救。
18.16.12 给团队负责人的建议
可观测性的升级是项目级投资——需要管理者的明确支持。把这套成熟度模型作为沟通工具——让团队和管理层对"可观测性"有共同理解。
理解成熟度模型后,"可观测性"不再是模糊概念——而是有清晰评估标准和升级路径的工程能力。
18.17 可观测性检查清单
| 维度 | 问题 | 方案 | 章节 |
|---|---|---|---|
| 日志 | WASM 内部发生了什么? | tracing-wasm 或自定义 StructuredWasmLayer → JS console/日志服务 | 18.2-18.3 |
| 结构化日志 | 日志能否被机器解析? | serde 序列化 + wasm-bindgen 传递 JsValue | 18.3 |
| 指标 | 实例数量、内存使用、调用频率? | Wasmtime Engine::metrics() → Prometheus | 18.4 |
| 性能 | 哪个函数是热点? | Chrome Performance + DWARF source map + 自定义计数器 | 18.5 |
| 分布式追踪 | 请求经过了哪些组件? | WIT tracing 接口 + OpenTelemetry + trace context 传播 | 18.6 |
| 错误 | 为什么 trap? | catch_unwind + panic::set_hook + 结构化错误日志 | 18.7 |
| 内存 | 是否泄漏? | memory.buffer.byteLength 增长趋势 + 自定义分配追踪 | 18.8 |
这份检查清单应该嵌入到项目的 CI/CD 流程中——每次发布前确认每个维度都有覆盖。与《tokio 异步运行时》一书中对异步任务监控的思路类似,可观测性不是"加几个日志"的事后补丁,而是从架构设计阶段就需要考虑的核心需求。一个没有可观测性的 WASM 模块在生产环境中就是一个黑盒——出了问题只能盲人摸象。
下一章用三个生产案例展示 WASM 在真实场景中的表现——从图像处理到密码学,每个案例都离不开本章讨论的可观测性基础设施。