Rust + WebAssembly 全链路解析

第18章 wasm-tracing 与可观测性

作者 杨艺韬 · 20,998 字 · 发布于 · 更新于

"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 没有操作系统内核,它运行在宿主提供的一个纯净的虚拟指令集中。

graph TD
  subgraph "原生 Rust 可观测性工具"
    A[strace: 系统调用追踪] --- B[perf: CPU 热点分析]
    B --- C["/proc: 内存/状态查询"]
    C --- D[tokio-console: 异步任务监控]
    D --- E[Valgrind / ASan: 内存泄漏检测]
    E --- F[eBPF: 内核态动态追踪]
  end
  subgraph "WASM 沙箱内的现实"
    G["strace — 无系统调用可追踪"] --- H["perf — 无 perf_event 可附着"]
    H --- I["/proc — 无文件系统可访问"]
    I --- J["tokio-console — 无异步运行时"]
    J --- K["Valgrind — 无 malloc/free 可拦截"]
    K --- L["eBPF — 无内核态可进入"]
  end
  A -.->|沙箱切断| G
  B -.->|沙箱切断| H
  C -.->|沙箱切断| I
  D -.->|沙箱切断| J
  E -.->|沙箱切断| K
  F -.->|沙箱切断| L
  style A fill:#10b981,color:#333
  style G fill:#dc2626,color:#fff

这意味着 WASM 的可观测性必须从内部主动构建——模块自己决定输出什么信息,宿主决定如何收集和展示。本章从 Rust 侧的 tracing 基础设施开始,逐步扩展到宿主侧的 metrics API、性能分析、分布式追踪和错误报告,最终给出一份可观测性检查清单。

WASM 可观测性的建设分为两个方向:从内向外的方案(模块主动输出信息给宿主)和从外向内的方案(宿主从外部观测模块行为)。前者包括 tracing 日志、结构化日志、自定义性能计数器;后者包括宿主侧的打点与资源钩子、浏览器/系统的采样 profiler、内存监控。两个方向互补——从内向外提供语义丰富的信息("正在处理图像"),从外向内提供精确的度量数据("这次调用耗时多少、消耗了多少 fuel")。

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 的后端。tracing 本身不带默认 subscriber——没装就直接丢弃事件;最常用的 tracing_subscriber::fmt 默认写 stdout(tracing-subscriber 0.3.23 里 fmt::Layer 的默认 writer 是 io::stdout)。

在浏览器 WASM 中,fmt 默认的 stdout 无处可去:WASI Preview 1 的 fd_write 只在有 WASI 支持的宿主里可用,浏览器环境根本没有标准 I/O 的概念。需要换一个 subscriber / layer,把日志导出到宿主。

console_log 方案

最简单的方案:把 tracing 事件转发到 JS 的 console.log:

use tracing::{info, warn, error, instrument};
// prelude 里同时有 SubscriberExt(.with)和 SubscriberInitExt(.init);
// 只引 layer::SubscriberExt 的话 .init() 编不过
use tracing_subscriber::prelude::*;
// 本章标题里的 wasm-tracing 就是这个 crate:tracing-wasm 的维护分叉。
// 类型名做了大小写归一:WASMLayer → WasmLayer,WASMLayerConfig → WasmLayerConfig
use wasm_tracing::WasmLayer;

fn init_tracing() {
    tracing_subscriber::registry()
        .with(WasmLayer::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
}

这个 WasmLayer 做两件事(按 wasm-tracing 2.1.0 的 Layer 实现):每个 tracing event 都经 console.log 输出——不分级别,全走 console.log,默认配置用 %c 样式按级别上色,并带上文件名和行号;span 则不进 Console,而是在进入时打一个 performance.mark、退出时做一次 performance.measure,于是每个 span 会以一段时长出现在 DevTools Performance 面板的 Timings 轨道上。它没有用 console.group / groupEnd,Console 里看到的是平铺的日志。想要按级别分流到 console.error / console.warn 这类方法(便于在 Console 里按级别过滤),可以换 tracing-web(0.1.3):它提供 MakeWebConsoleWriter 给 tracing_subscriber::fmt 当 writer,按级别调用 console.debug/info/warn/error,另有 performance_layer() 负责 Performance API 那一路。

先把 crate 选对:很多资料里写的是 tracing-wasm,那个 crate 在 crates.io 上停在 2021 年发布的 0.2.1,此后没有再发版;wasm-tracing 自称是它的维护分叉,写作时是 2.1.0(2025 年 8 月发布)。两者 API 高度同构,差别主要在命名大小写(WASMLayer/WASMLayerConfig → WasmLayer/WasmLayerConfig),以及配置方式:tracing-wasm 用 WASMLayerConfigBuilder 构建配置,wasm-tracing 把 set_* 方法直接放在 WasmLayerConfig 上(ConsoleConfig 两边都有)。只想要一行接入的话,两个 crate 都提供 set_as_global_default() / set_as_global_default_with_config() / try_set_as_global_default(),不必自己拼 registry。

这个方案的开销由三部分组成:span 创建与字段记录(纯 Rust 侧)、字段格式化成字符串(可能触发堆分配)、以及一次跨边界的 console.* 调用。第三项是最贵的,它和第 6 章拆解的字符串跨边界传递是同一类开销(复制 + 分配/释放 + 一次 JS 调用)。结论很直接:热路径上不要记日志——只在函数入口/出口/错误处记录。如果对图像的每个像素都记一条日志,观测开销会远超被观测的计算本身,这是典型的"观测开销压垮被观测对象"反模式。

自定义 subscriber 控制开销

更精细的做法是自定义 Layer,按级别和 span 名过滤——这在不修改业务代码的前提下控制观测开销:

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::default().with_filter(filter))
        .init();
}

这样 image_processor 模块只输出 INFO 及以上级别的日志,crypto_module 只输出 WARN 及以上——密钥派生等高频操作不会产生日志洪流。如果给 tracing 打开了静态级别上限的 cargo feature(如 max_level_error),编译期就做了第一次筛选——此时 info! 展开后的判断是常量 false,整段被优化掉;Targets filter 做的是运行时的第二次筛选——确保即使宏生成了代码,不需要的模块也不会输出。

日志级别的策略设计

在实际项目中,日志级别不是简单的"开发用 DEBUG,生产用 ERROR"——需要一个更精细的策略:

级别 何时使用 WASM 场景示例 典型频率
ERROR 必须立即处理的问题 加密失败、内存耗尽、数据损坏 罕见(每天 < 10 次)
WARN 可能导致问题但不致命 输入接近上限、降级到后备方案 偶尔(每小时 < 10 次)
INFO 关键业务事件 函数调用入口/出口、请求开始/结束 按请求(每个请求 1-5 次)
DEBUG 调试时的诊断信息 中间计算结果、状态转换 仅开发环境
TRACE 极细粒度的执行追踪 循环内部状态、内存指针值 仅定位特定 bug 时

关键原则:WASM 热路径上的 DEBUG/TRACE 日志应该完全不存在——不是"运行时过滤掉",而是"编译时不生成"。做法是给 tracing 打开 max_level_info(或只作用于 release 构建的 release_max_level_info)feature(tracing 0.1.44 Cargo.toml 的 [features]),此时所有 debug! 和 trace! 调用在编译期就被消除——零运行时开销。

18.3 结构化日志:从文本到数据

console.log 的文本日志不利于机器解析。更好的方案是通过 wasm-bindgen 把结构化日志传给 JS——这在《Serde 元编程》专栏第 2 章(数据模型)对序列化的讨论中同样适用:数据只有结构化之后才能被下游系统消费。

文本日志的问题不仅是"难以解析"——更根本的问题是"信息丢失"。当 info!("Processing image: width={}, height={}", w, h) 输出 "Processing image: width=3840, height=2160" 时,宽度和高度的信息被埋入文本中——下游系统需要正则匹配才能提取。如果字段名变更(width → w),所有解析逻辑都会断裂。结构化日志保留了字段的名值对关系,下游系统可以按字段名精确查询。

结构化日志方案

use wasm_bindgen::prelude::*;
use serde::Serialize;   // 下面的 .serialize(...) 也要用到这个 trait
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,
    };
    // 注意:JsValue::from_serde / into_serde 在 wasm-bindgen 中**已标记 deprecated**
    // (官方理由:会造成依赖环),需要 `serde-serialize` feature 才能编译。
    // 现在的做法是用 serde-wasm-bindgen,它直接构造 JS 值,
    // 不像 from_serde 那样先 to_string 再 JSON.parse,更快也更小。
    // 注意用 json_compatible():默认的 to_value 会把 serde_json::Value 里的对象
    // 序列化成 JS Map,JSON.stringify(Map) 得到 {},上报时字段就全丢了
    let js_entry = entry
        .serialize(&serde_wasm_bindgen::Serializer::json_compatible())
        .unwrap();
    collect_log(&js_entry);
}

JS 侧的 __log_collector 可以做任意后端对接:

// 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 请求的开销不可接受。批大小和超时时长要按自己的日志速率和可接受的丢失窗口来定:批越大越省请求数,但进程被关掉时丢的日志也越多;还要配一个超时兜底,否则低流量时日志会一直压在缓冲区里发不出去。

把 tracing 集成为结构化日志的 subscriber

更优雅的方案不是手动调用 emit_structured_log,而是实现一个自定义 Layer,自动把 tracing 事件转换为结构化日志——业务代码完全不感知后端的存在:

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));

        // current_span() 返回 span::Current,名字要经 metadata() 取
        let span_name = ctx.current_span().metadata().map(|m| m.name().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 = entry
            .serialize(&serde_wasm_bindgen::Serializer::json_compatible())
            .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));
    }
    // record_debug 是 Visit 唯一的必需方法,不能省:
    // info!("...") 的 message 字段就是经它(以 fmt::Arguments)记录的,
    // 没有它不但编不过,message 也永远取不到
    fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
        self.0.insert(field.name().to_string(), Value::String(format!("{value:?}")));
    }
    // record_i64 等其他类型省略,未覆写的会回落到 record_debug
}

这个 subscriber 的优势在于:业务代码只需使用标准的 tracing 宏(info!、warn!、error!),日志的后端传输方式完全由 subscriber 决定——切换后端不需要改动业务代码。这种分离在《Serde 元编程》专栏第 3 章(Serializer trait)讨论的序列化抽象中也有体现:数据模型和序列化格式的解耦,让同一份数据可以适配不同的传输层。

flowchart LR
  subgraph "Rust WASM 模块"
    A["tracing::info!<br/>tracing::warn!<br/>tracing::error!"] --> B["StructuredWasmLayer<br/>(自定义 Layer)"]
    B --> C["serde-wasm-bindgen<br/>(构造 JsValue)"]
  end
  C --> D["window.__log_collector<br/>(JS 回调)"]
  subgraph "JS 侧后端选择"
    D --> E["DevTools console<br/>(开发环境)"]
    D --> F["Grafana Loki<br/>(生产环境)"]
    D --> G["OpenTelemetry Collector<br/>(分布式追踪)"]
  end
  style B fill:#6366f1,color:#fff
  style D fill:#f59e0b,color:#333

结构化日志的体积权衡

serde + serde_json 会给 .wasm 带来一块可观的固定体积——serde_json 要带上完整的 JSON 词法/语法处理和 Value 这个动态类型,即使你只用它序列化几个固定结构体。具体多少字节要用第 9 章讲的 twiggy 在自己的产物上量。如果体积敏感,两条路:一是 serde-json-core(no_std,不支持 serde_json::Value 这类动态类型,必须预先定义所有日志结构体——日志场景通常可以接受);二是干脆不做 JSON,把字段用 serde-wasm-bindgen 直接构造成 JS 对象,省掉整个 JSON 文本层。

另一个体积优化方向是避免在每次日志调用时分配 String——用 heapless::String 或预分配的缓冲区替代动态字符串。这在嵌入式和 no_std 场景中常见;在浏览器 WASM 中收益要看具体情况——分配发生在 WASM 自己的线性内存里(走 dlmalloc),和 JS 引擎的字符串优化无关,但相对那一次跨边界调用的成本,这点分配通常不是瓶颈。

18.4 宿主侧:Wasmtime 的 metrics API

WASM 模块内部的观测只是故事的一半——宿主侧也能收集模块运行时的宏观数据,且不需要修改模块代码。这是"从外向内"的观测方向。

先澄清:Wasmtime 没有通用的"内置 metrics API"

必须先纠正一个流传很广的说法:Wasmtime 44 里不存在 Engine::metrics(),也不存在 Config::wasm_metrics() 这样的开关。Store 上同样没有 metrics() 或 set_profiler()——整个 wasmtime-44 源码树里搜不到 fn metrics,而 profiler 开关真正的名字是 Config::profiler(ProfilingStrategy)(crates/wasmtime/src/config.rs:1354)。唯一沾边的是 Engine::pooling_allocator_metrics()(crates/wasmtime/src/engine.rs:781):只在启用池化分配器时返回 Some,给出池里当前的核心/组件实例数、内存与表的槽位占用、闲置但仍驻留的字节数这类池子占用数据,适合喂给容量监控,但不涉及调用耗时、trap 次数这些业务指标。宿主侧的可观测性主要靠三类真实存在的机制自己拼出来。

机制一:宿主打点。编译、实例化、调用三段各自计时——这是最直接也最有用的一层,因为它天然按你的业务边界切分:

use std::time::Instant;

let t0 = Instant::now();
let module = Module::from_file(&engine, path)?;      // 编译(命中缓存时几乎为零)
let t1 = Instant::now();
let instance = linker.instantiate(&mut store, &module)?;
let t2 = Instant::now();
let f = instance.get_typed_func::<(), ()>(&mut store, "process")?;
let call_result = f.call(&mut store, ());
let t3 = Instant::now();

metrics::histogram!("wasm_compile_seconds").record((t1 - t0).as_secs_f64());
metrics::histogram!("wasm_instantiate_seconds").record((t2 - t1).as_secs_f64());
metrics::histogram!("wasm_call_seconds").record((t3 - t2).as_secs_f64());
if call_result.is_err() {
    metrics::counter!("wasm_trap_total").increment(1);
}

机制二:fuel 记账。开启 Config::consume_fuel(true) 后,用调用前后的 Store::get_fuel() 差值得到这次调用确定性的工作量——它不受机器快慢和调度抖动影响,是比挂钟时间更适合做配额与计费的指标:

let before = store.get_fuel()?;
let r = f.call(&mut store, ());
let consumed = before - store.get_fuel()?;
metrics::histogram!("wasm_fuel_consumed").record(consumed as f64);

机制三:ResourceLimiter 钩子。Store::limiter 注册的 limiter,其 memory_growing / table_growing 会在每次增长前被调用——这正是统计 memory.grow 次数与增长量的位置,顺便还能在这里执行上限:

use wasmtime::ResourceLimiter;

struct LimiterWithMetrics {
    max_memory: usize,
    grow_count: u64,
}

impl ResourceLimiter for LimiterWithMetrics {
    fn memory_growing(
        &mut self,
        current: usize,
        desired: usize,
        _maximum: Option<usize>,
    ) -> wasmtime::Result<bool> {   // 44 里是 wasmtime::Result,写 anyhow::Result 与 trait 签名不符
        self.grow_count += 1;
        metrics::counter!("wasm_memory_grow_total").increment(1);
        metrics::gauge!("wasm_linear_memory_bytes").set(desired as f64);
        Ok(desired <= self.max_memory)   // 返回 false 即拒绝增长
    }

    fn table_growing(
        &mut self,
        _current: usize,
        _desired: usize,
        _maximum: Option<usize>,
    ) -> wasmtime::Result<bool> {
        Ok(true)
    }
}

注意 memory_growing 的 current/desired 是字节数,table_growing 的是元素个数,别混用。另外实例化时分配初始线性内存也会调一次 memory_growing(current 为 0)——实测一个声明 1 页内存的模块,实例化后执行一次 memory.grow,计数是 2——统计 memory.grow 次数时要把这一次扣掉,或者只数 current > 0 的调用。

这三类指标各自的诊断意义:

指标 来源 诊断意义
编译耗时 宿主打点 占比高说明缓存没命中,该上编译缓存或 AOT 预编译
实例化耗时 宿主打点 占比高说明该上 InstancePre 预链接 / PoolingAllocationConfig
调用耗时 宿主打点 真正的业务执行时间,配 P95/P99 看分布
fuel 消耗 Store::get_fuel 差值 确定性工作量;突增说明输入或算法路径变了
memory.grow 次数 ResourceLimiter 单次请求内多次增长 = 初始内存声明得太小
线性内存字节数 ResourceLimiter 长期单调上升 = 泄漏(见 §18.8)
trap 次数 调用返回值 非零即表示模块有运行时错误

每个指标的解读要结合场景。memory.grow 出现 10 次,在一个长期运行、逐步预热的边缘计算组件里完全正常;但如果是单次请求内触发了 10 次,那就是初始内存声明得太小——应该在链接期把 --initial-memory 调大(见 §17.7)。

构建 metrics dashboard

上面这些打点都在宿主侧,不需要修改 WASM 模块代码。用 metrics crate 加 metrics-exporter-prometheus 就能直接暴露成 Prometheus 端点:

use metrics_exporter_prometheus::PrometheusBuilder;

// 进程启动时装一次全局 recorder,之后所有 metrics::* 宏都会落到它上面
let handle = PrometheusBuilder::new().install_recorder()?;

// 在 /metrics 路由里返回 exposition 格式文本
async fn metrics_endpoint(handle: PrometheusHandle) -> String {
    handle.render()
}

在服务器端 WASM 场景中(第 17 章讨论的边缘计算和插件系统),这个端点可以直接挂到 Rust 后端框架的路由上(如 axum,参考《Axum 设计与实现》专栏),暴露给 Prometheus + Grafana 监控栈,再按 §18.10 的规则配告警。

打点开销的取舍

宿主侧打点的开销和"打多细"直接相关:

  • 每次调用一次计时 + 一次 histogram 记录:相对一次 WASM 调用本身可以忽略,放心用
  • 开启 consume_fuel:这是编译期插桩,会改变生成的代码,属于全局性的成本;Wasmtime 文档自己把它描述为"somewhat expensive",只在确实需要确定性配额时开
  • ResourceLimiter:只在 memory.grow / table.grow 时触发,是低频事件,开销可忽略
  • 回溯带文件名/行号:要靠 Config::wasm_backtrace_details(WasmBacktraceDetails::Enable)(或环境变量 WASMTIME_BACKTRACE_DETAILS=1)让 Wasmtime 在内存里保留模块的 DWARF、trap 时拿来符号化(crates/wasmtime/src/config.rs:496-509 的文档),DWARF 往往比代码大几十倍(见 §18.5 的实测),常驻内存的代价不小,只在排查时开;Config::debug_info(true) 是另一回事——它为宿主进程上的 gdb/lldb 这类原生调试器生成调试信息(crates/wasmtime/src/config.rs:421-430)

如果只需要"周期性地知道模块还活着、跑到哪了"而不需要精确计数,用 epoch 中断(§17.10.2)在 epoch 边界采样,比 fuel 的精确计数轻得多。

18.5 性能分析:WASM 的 profiling 方案

Chrome DevTools Performance 面板

Chrome 的 Performance 面板可以录制 WASM 函数的调用栈和耗时。要看到 Rust 函数名而不是 wasm-function[42],最低要求是 .wasm 保留 name 段;要进一步定位到源文件和行号,则需要 DWARF 调试信息,并在 DevTools 中装上官方的 "C/C++ DevTools Support (DWARF)" 扩展(名字带 C/C++,对 Rust 同样有效)——DWARF 支持不是 DevTools 内建的。

编译配置:

# 编译时带调试信息
cargo build --target wasm32-unknown-unknown --profile profiling

# wasm-bindgen 保留 wasm 中的调试段(--keep-debug)
# 注意:wasm-bindgen CLI 没有 --profiling 选项,那是 `wasm-pack build --profiling` 的开关
wasm-bindgen --target web --keep-debug --out-dir pkg target/wasm32-unknown-unknown/profiling/my_lib.wasm

另外要留意 wasm-opt:不加 -g 时它会丢弃调试段,连 name 段也不输出,需要显式加 -g 才能保留。wasm-bindgen 这一步默认保留 name 段(有 --remove-name-section 开关可删),DWARF 段则要 --keep-debug 才留下。用 wasm-pack 构建的话注意:wasm-pack build --profiling 给 cargo 传的是 --release,并不会用下面这个 [profile.profiling],要用 wasm-pack build --profile profiling,并在 [package.metadata.wasm-pack.profile.custom] 里配(--profile <名字> 走 Custom 分支,读的是 custom 这一节,src/manifest/mod.rs:539) wasm-bindgen.dwarf-debug-info = true、wasm-opt = ["-O", "-g"](见第 8 章 §8.12.2)。

对应的 Cargo.toml profile 定义:

[profile.profiling]
inherits = "release"
opt-level = 3          # 保留 release 级优化——否则 profiling 结果不反映真实性能
debug = true           # 生成完整 DWARF 调试信息(cargo profile 的键是 debug,不是 debug_info)
strip = false          # 不 strip 符号
lto = true
codegen-units = 1

顺带一提:lto = true + codegen-units = 1 会让编译显著变慢,但 profiling 构建必须和 release 保持同样的优化配置,否则测出来的热点不是生产环境的热点。

在 Performance 面板中,有 name 段时 WASM 函数显示为 Rust 函数名(而非 wasm-function[42]),火焰图里就能直接认出"热点在 grayscale_simd"。DWARF 加扩展带来的是源码级调试:在 Sources 面板里按 .rs 文件下断点,Call Stack 显示源码里的函数名,Scope 显示局部/全局变量及其类型和值(Chrome DevTools 文档《Debug C/C++ WebAssembly》)。还要注意同一篇文档的提醒:DevTools 打开并完成调试设置后,Chrome 运行的是降级(tier down)后的未优化代码,这时代码里的 performance.now() / console.time() 计时不可信,要么用 Performance 面板录制,要么先在不开 DevTools 的情况下跑完计时代码,再打开 DevTools 看 Console 输出。

flowchart LR
  A["Rust 源码<br/>src/crypto.rs:42"] --> B["rustc 编译<br/>+ DWARF 信息"]
  B --> C[".wasm 二进制<br/>+ DWARF 段"]
  C --> D["Chrome DevTools<br/>+ C/C++ DevTools Support 扩展"]
  D --> E["Sources 面板按 .rs 行断点<br/>Scope 显示 Rust 变量"]
  style A fill:#10b981,color:#333
  style E fill:#6366f1,color:#fff

DWARF 信息的代价是产物显著变大——调试段里要放完整的类型、变量、行号映射,体积通常远超代码本身。实测本章的示例 crate(依赖 tracing、wasm-tracing、serde_json、serde-wasm-bindgen,Rust 1.96.0 release + debug = true,wasm-bindgen 0.2.118):不带 --keep-debug 的产物 150,491 字节,其中 code 段 92,114 字节、name 段 37,200 字节;带 --keep-debug 后变成 6,025,258 字节,多出的 5,874,741 字节全是 .debug_* 段,约为 code 段的 64 倍。profiling 构建不用于生产,只在性能调查时使用;要量化自己项目的差异,直接比对带/不带 --keep-debug 的两份产物大小。一个常见的工程实践是:在 CI 中保留 profiling 构建产物,当生产环境出现性能退化时,用 profiling 构建复现问题。

自定义性能计数器

对于需要精细性能分析的模块,可以在 Rust 代码中插入性能计数器,并集成到浏览器的 Performance Timeline:

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> {
    // 上面 extern 块里声明的是自由函数 `mark` / `measure`
    // (`js_namespace = performance` 只影响它们在 JS 侧的查找路径),
    // 所以 Rust 这边直接按函数名调用,不写 `performance::` 前缀
    mark("process_start");

    let result = do_process(data);

    mark("process_end");
    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 是更实用的方案。其原理是周期性地记录"当前正在执行什么",用样本的分布近似耗时的分布。

但浏览器 WASM 中自己实现采样 profiler 是行不通的,原因很根本:原生环境里 perf 靠内核定时中断读取指令指针(RIP),是硬件+内核级操作,对被观测线程几乎零干扰;浏览器里 JS 的 setInterval / setTimeout 回调只能排进事件循环,而 WASM 调用是同步的——它跑的时候事件循环根本轮不到,定时器一次也不会触发。等它返回了,你想采样的那段执行已经结束了。

所以浏览器端的正解只有一条:用 DevTools 的 Performance 面板,它由浏览器在引擎内部做真正的采样,能拿到 WASM 帧。服务器端则用 Wasmtime 自带的 profiling 支持(见本节末尾)。

下面这种"在 Rust 里维护采样缓冲区、靠 JS 定时器触发"的写法是常见的想当然实现,列在这里是为了说明它为什么不成立:

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 端分析
        serde_wasm_bindgen::to_value(&profiler.samples).unwrap()
    })
}

除了定时器根本不会在同步调用期间触发这个致命问题,这种写法还有第二重障碍:WASM 里没有"读取当前调用栈"的原语。WebAssembly.Module.customSections() 读的是模块的自定义段(静态元数据),和运行时栈毫无关系;唯一能拿到栈的办法是构造一个 JS Error 读 .stack,那是一次跨边界调用加一次栈字符串化,成本高到不可能用于高频采样。

真要在代码里自己度量,退而求其次的做法是手动分段计时(前面的 performance.mark/measure),按函数或阶段打点,而不是试图做通用采样器。

服务器端:Wasmtime 的 profiling

Config::profiler 接受的 ProfilingStrategy 在 44 里有 None、PerfMap、JitDump、VTune、Pulley 五个取值(crates/wasmtime/src/config.rs:3337):PerfMap 把 JIT 代码的地址与函数名写进 /tmp/perf-<pid>.map 供 perf 符号化,JitDump 生成 perf inject 能用的 jitdump,VTune 对接 Intel VTune,Pulley 用于 Wasmtime 自己的解释器(需编译期开 profile-pulley feature)。命令行对应 wasmtime run --profile=perfmap|jitdump|vtune|pulley,另外还有一个不依赖外部工具的 --profile=guest[,路径[,采样间隔]](src/common.rs:463-487,默认写 wasmtime-guest-profile.json、间隔 10ms):它借 epoch 中断在进程内定时采样,产物用 Firefox Profiler(profiler.firefox.com)打开。嵌入方要在自己的宿主里做同样的事,用的是 wasmtime::GuestProfiler——在 Store::epoch_deadline_callback 回调里调 GuestProfiler::sample(crates/wasmtime/src/runtime/profiling.rs 顶部的文档);按文档所说,这种方式只在函数入口和循环头采样,精度不如 perf,但跨平台,而且可以只针对单个实例开启。实测 wasmtime 44.0.0 跑一个 wasm32-wasip1 的计算循环:--profile=guest,prof.json,1ms 生成的 JSON 里样本按 name 段显示为 p1::hot::h8c58…;--profile=perfmap 写出的 /tmp/perf-<pid>.map 里同样是 p1::hot::…、p1::main::… 这样的 Rust 符号。

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:

use wasm_bindgen::prelude::*;

#[wasm_bindgen]
extern "C" {
    // JS 侧的 window.__otel 是一个带三个方法的对象,所以命名空间要写到 __otel 这一层,
    // js_name 指向具体方法;三处都写 js_name = "__otel" 会变成把 __otel 当函数调用
    #[wasm_bindgen(js_namespace = ["window", "__otel"], js_name = "startSpan")]
    fn start_span(name: &str, trace_id: &str, parent_span_id: &str) -> JsValue;
    #[wasm_bindgen(js_namespace = ["window", "__otel"], js_name = "endSpan")]
    fn end_span(span_id: &str);
    #[wasm_bindgen(js_namespace = ["window", "__otel"], js_name = "addSpanAttribute")]
    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 数据:

import { WebTracerProvider, BatchSpanProcessor } from '@opentelemetry/sdk-trace-web';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
import { context, trace, TraceFlags } from '@opentelemetry/api';

// SDK 2.x:exporter 要包进 span processor,再经 spanProcessors 传入
const provider = new WebTracerProvider({
  spanProcessors: [new BatchSpanProcessor(new OTLPTraceExporter())],
});
provider.register();

const activeSpans = new Map();

window.__otel = {
  startSpan(name, traceId, parentId) {
    const tracer = provider.getTracer('wasm-module');
    // 父子关系要通过 context 传入;写成 links 只是"关联",不会成为父 span
    const parentCtx = parentId
      ? trace.setSpanContext(context.active(),
          { traceId, spanId: parentId, traceFlags: TraceFlags.SAMPLED })
      : context.active();
    const span = tracer.startSpan(name, {}, parentCtx);
    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 目前没有标准化的日志、追踪接口:WASI 提案列表(WebAssembly/WASI docs/Proposals.md,2026-09-24 核对)里 wasi-logging、wasi-otel、wasi-observe 都还在 Phase 1,Wasmtime 44 源码树里也没有 wasi:logging 的实现。wasi-logging 的接口只有一个 log: func(level: level, context: string, message: string),管得了日志,管不了 trace context。实践中通过自定义的 WIT 接口传递 trace context——这与第 14 章组件模型的 WIT 接口设计模式一脉相承(注意 WIT 里没有 void,不返回值就直接省略 ->):

package example:observability;   // 单独成包时必须有 package 头,否则 wit 解析报错

interface tracing {
    /// 一个分布式追踪的 span
    resource span {
        /// 为 span 添加键值对属性
        set-attribute: func(key: string, value: string);
        /// 记录一个错误事件
        record-error: func(msg: string);
        /// 结束 span
        end: func();
    }

    /// 获取当前的活跃 span
    get-current-span: func() -> span;

    /// 创建子 span。parent 用 borrow:写成 option<span> 会把父 span 的所有权
    /// 交给宿主,guest 这边的父 span 句柄随之失效(wit-bindgen 0.57 实测:
    /// 之后再用父 span 报 E0382 borrow of moved value)
    start-span: func(name: string, parent: option<borrow<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:

下面是宿主侧实现的形状示意。opentelemetry 生态至今(crates.io 上 0.33.0)仍未到 1.0,经历过多次拆包和重命名(SDK 类型从 opentelemetry::sdk::* 移到了独立的 opentelemetry_sdk crate),实际写的时候请以你锁定版本的 docs.rs 为准。示意里只写各方法的业务逻辑;真正由 wasmtime::component::bindgen! 生成的 Host / HostSpan trait,参数和返回值是 Resource<Span> 句柄,要在 ResourceTable 里取出下面这个 OtelSpan,还要实现 drop,这些样板代码从略。方法体按 opentelemetry / opentelemetry_sdk 0.33.0 cargo check 通过:

use opentelemetry::trace::{Span, Status, TraceContextExt, Tracer};
use opentelemetry::{Context, KeyValue};
use opentelemetry_sdk::trace as sdktrace;

// 宿主侧实现(形状示意)
struct OtelSpan {
    inner: sdktrace::Span,
}

impl OtelSpan {
    fn set_attribute(&mut self, key: String, value: String) {
        self.inner.set_attribute(KeyValue::new(key, value));
    }

    fn record_error(&mut self, msg: String) {
        self.inner.set_status(Status::error(msg));
    }

    fn end(&mut self) {
        self.inner.end();
    }
}

struct ComponentHost {
    tracer: sdktrace::Tracer,
}

impl ComponentHost {
    fn start_span(&mut self, name: String, parent: Option<&OtelSpan>) -> OtelSpan {
        let builder = self.tracer.span_builder(name);
        let span = match parent {
            // Span trait 上取的是 span_context(),再挂到一个 Context 上作为父级
            Some(p) => {
                let cx = Context::new().with_remote_span_context(p.inner.span_context().clone());
                builder.start_with_context(&self.tracer, &cx)
            }
            None => builder.start(&self.tracer),
        };
        OtelSpan { inner: span }
    }

    fn inject_context(&mut self, trace_id: String, span_id: String) -> OtelSpan {
        // 标准做法是把上游的 trace-id / span-id 拼回 W3C traceparent 头,
        // 交给 TraceContextPropagator::extract 还原成 Context——
        // 而不是自己拼 SpanContext(没有 with_remote_trace_context 这个方法)
        use opentelemetry_sdk::propagation::TraceContextPropagator;
        use opentelemetry::propagation::TextMapPropagator;

        let carrier = std::collections::HashMap::from([(
            "traceparent".to_string(),
            format!("00-{trace_id}-{span_id}-01"),
        )]);
        let context = TraceContextPropagator::new().extract(&carrier);
        let span = self.tracer.start_with_context("wasm-component", &context);
        OtelSpan { inner: span }
    }
}

WASM 组件调用自定义 tracing 接口的 start-span 创建 span(这不是 WASI 标准接口)。跨组件的传播要靠宿主自己做:宿主在把请求转给下一个组件之前,用当前 span 的 trace-id / span-id 调它的 inject-context(或随请求带上这两个值),组件模型本身不会替你传递。这样才串起跨 WASM 组件的分布式追踪。

sequenceDiagram
  participant G as API Gateway
  participant A as 组件 A: Auth
  participant B as 组件 B: Business Logic
  participant C as 组件 C: Data Access
  participant OT as OpenTelemetry Collector
  G->>A: request + trace-context
  Note over A: start-span("auth", parent=gateway-span)
  A->>A: validate_token()
  A->>B: request + trace-context
  Note over B: start-span("process", parent=auth-span)
  B->>C: query + trace-context
  Note over C: start-span("db-query", parent=process-span)
  C-->>B: result
  B-->>A: result
  A-->>G: response
  A->>OT: export auth-span
  B->>OT: export process-span
  C->>OT: export db-query-span
  Note over OT: 三个 span 通过 trace-id 关联<br/>形成完整调用链

这种设计的关键在于:WASM 组件本身不需要知道 OpenTelemetry 的存在——它只调用 WIT 定义的 tracing 接口。具体的后端实现(OpenTelemetry、Jaeger、Zipkin)完全由宿主决定。这是能力安全哲学的体现——组件只需声明"我需要追踪能力",宿主决定如何满足。这与《Tokio 源码深度解析》专栏第 17 章(可观测性)中 tracing 集成的思路一致——运行时提供 tracing 基础设施,业务代码只负责发出事件。

trace context 传播的 W3C 标准

W3C 定义了 Trace Context 标准(W3C Recommendation,不是 IETF RFC),规定了 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 上下文中——组件内后续的 span 通过 start-span 的 parent 参数挂到这条链上,下游组件则由宿主按同样方式再注入一次。

18.7 错误报告:让 trap 不再是黑盒

trap 的处理

WASM trap(越界访问、除零等)在宿主侧表现为 wasmtime::Trap——但拿到它的方式有两处常见写错的地方。

Wasmtime 44 的调用错误类型是 wasmtime::Error——它已经是 Wasmtime 自己的错误类型(定义在 crates/core/src/error/error.rs:425,由 wasmtime 重新导出),不再是 anyhow::Error 的别名,开了 anyhow feature 时两者可以互转。它没有 is_trap() 这个方法——判断方式是 downcast_ref::<Trap>()。同理,Instance 上没有 call_async,调用要先 get_typed_func 拿到 TypedFunc 再 call / call_async:

use wasmtime::Trap;

let func = instance.get_typed_func::<(u32, u32), u32>(&mut store, "process")?;
match func.call_async(&mut store, args).await {
    Ok(result) => { /* 正常处理 */ }
    Err(e) => match e.downcast_ref::<Trap>() {
        Some(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,
                );
                metrics.memory_oob_count.fetch_add(1, Ordering::Relaxed);
            }
            Trap::IntegerDivisionByZero => {
                report_error("Division by zero in WASM", ErrorSeverity::Warning);
            }
            // Rust 的 panic 在 WASM 里表现为 unreachable 指令
            Trap::UnreachableCodeReached => {
                report_error("panic in guest (unreachable trap)", ErrorSeverity::Critical);
            }
            // fuel 耗尽、epoch 超时也是 Trap:OutOfFuel / Interrupt
            Trap::OutOfFuel | Trap::Interrupt => {
                report_error("guest exceeded fuel / time budget", ErrorSeverity::Warning);
            }
            other => report_error(&format!("Trap: {other:?}"), ErrorSeverity::Unknown),
        },
        // 不是 trap:宿主函数(导入函数)返回的错误等
        None => return Err(e),
    },
}

光看 Trap 枚举确实只有类型,没有位置。但 Wasmtime 会把 WASM 侧的回溯作为 context 挂在同一个 error 上——用 e.downcast_ref::<wasmtime::WasmBacktrace>() 就能取出帧列表;对于内存越界这类由信号触发的 trap,它还会把出错的线性内存地址一并作为 context 附上(crates/wasmtime/src/runtime/trap.rs:105-119)。实测 Wasmtime 44 下一次越界读的完整错误链是:memory fault at wasm address 0x7ffffff0 in linear memory of size 0x10000 → wasm trap: out of bounds memory access。帧里的函数名来自模块的 name 段,默认就有;要再显示源文件和行号,需要模块保留 DWARF,并开 Config::wasm_backtrace_details(WasmBacktraceDetails::Enable)(或设环境变量 WASMTIME_BACKTRACE_DETAILS=1)——注意不是 Config::debug_info,后者是给 gdb/lldb 用的。代价是产物更大、DWARF 常驻内存,所以通常只在 debug/profiling 构建里开。

panic 的可观测性

Rust 的 panic! 在 WASM 中默认调用 abort() 触发 trap。trap 本身只说明执行到了 unreachable 指令——不带 panic 消息。这在生产环境中是不可接受的——用户报告"白屏",开发者只能看到一行 trap 文本,完全无法定位问题。trap 文本因宿主而异:浏览器 / Node(V8)里是 RuntimeError: unreachable(Node v24 实测),Wasmtime 44 里是 wasm trap: wasm `unreachable` instruction executed(即 Trap::UnreachableCodeReached 的描述文本,wasmtime run 实测)。V8 的 RuntimeError 带着 .stack,有 name 段时能看到 __rust_start_panic、panic_with_hook 这些帧名,但默认只保留 10 帧,业务函数往往已被截掉;wasm32-wasip1 程序则会先经 std 的默认 hook 把 panic 消息打到 stderr,再 trap。

更可观测的方案是用 std::panic::catch_unwind 捕获 panic:

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 在浏览器 WASM 上基本指望不上——wasm32-unknown-unknown 这个目标本身的 panic 策略就是 abort(稳定版上用 rustc --print cfg --target wasm32-unknown-unknown 能看到 panic="abort";--print target-spec-json 的 panic-strategy 字段也是 abort,但那个打印选项要加 -Z unstable-options,稳定版直接报错),panic 直接落到 unreachable 指令,没有栈可展开,catch_unwind 自然也捕不到。真正的展开需要 WASM 的异常处理(exception handling,2025 年已标准化)支持,而 Rust 这边要走 nightly + -Zbuild-std 重编 std(见第 20 章 §20.4),不适合生产。

也就是说,在 Cargo.toml 里写 panic = "unwind" 对这个目标不会让 catch_unwind 变得可用(Rust 1.96.0 实测照常编译,产物与不写时一样都是 594,497 字节,std 仍是预编译的 abort 版)。反过来,panic = "abort" 在这个目标上也只是把默认值再写一遍——本来就没有展开代码可砍,实测本章示例 crate 写与不写分别是 594,520 与 594,497 字节,差别可以忽略。它的价值在于让配置显式、换到默认 unwind 的目标时行为不变:

[profile.release]
panic = "abort"     # wasm32-unknown-unknown 的默认值就是 abort,写上只为显式

对于生产环境的 panic 观测,更可靠的方式是设置自定义 panic hook——它在 panic = "abort" 模式下仍然生效:

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 在 abort 策略下仍然生效——hook 是在决定终止之前被调用的。这样即使在 release 构建中,panic 消息、文件名和行号也能被记录下来。关键区别是:catch_unwind 试图让 panic 变成可恢复的错误(在浏览器 WASM 上做不到),而 set_hook 只是在不可恢复之前抢救出诊断信息。对浏览器 WASM 来说,set_hook + 结构化日志是唯一可靠的 panic 观测手段——社区常用的 console_error_panic_hook(0.1.7)就是这条路的现成实现:它在 hook 里 new Error() 取 JS 栈,把 panic 消息连同栈一起交给 console.error;它的文档也提醒浏览器通常只捕获前 10 帧,Rust 程序往往不够,可以调大非标准的 Error.stackTraceLimit。

服务器端不一样:宿主是原生 Rust 进程,guest 的 panic 只是把这一次调用变成一个 trap(Trap::UnreachableCodeReached),宿主用上面的 downcast_ref::<Trap>() 就能捕获并继续服务——沙箱天然提供了"错误不扩散"的边界。

错误分类和告警策略

在生产环境中,不同的错误类型需要不同的响应策略:

错误类型 严重性 响应策略 示例
预期错误(业务逻辑) 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 持续增长——最终达到内存上限(模块声明的 maximum;没声明时是 32 位地址空间的 4GiB 或引擎自己的更低上限)导致 memory.grow 失败。

WASM 内存泄漏与原生 Rust 内存泄漏有本质区别。原生 Rust 的内存泄漏通常是"忘记 drop 某个值"——Valgrind 或 ASan 可以精确追踪每次分配和释放,定位泄漏的分配点。WASM 中没有这样的工具——宿主只看得到 memory.grow 这条指令向引擎申请整页(64KiB)扩容,WASM 模块内部的分配(Rust 标准库默认的 dlmalloc,或老教程里已停止维护的 wee_alloc)都发生在线性内存里,对宿主不可见。宿主只能看到线性内存的总大小在增长,无法知道是哪个数据结构在泄漏。

JS 侧监控内存增长

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" 状态。更麻烦的是它多数情况下不报错:旧视图的 byteLength 变成 0,索引读返回 undefined,索引写被静默丢弃,只有 slice() 这类方法才抛 TypeError(Node v24 实测,与第 11 章 §11.5.3 一致)。这意味着 JS 侧缓存的 Uint8Array 视图在 memory.grow 后会悄悄失效——必须在每次 memory.grow 后重新创建视图:

// 错误:缓存的视图在 memory.grow 后失效
const cachedView = new Uint8Array(wasm.memory.buffer);
wasm.some_function_that_grows_memory(); // 可能触发 memory.grow
cachedView[0]; // undefined——不抛错,静默读到错误结果

// 正确:每次访问前检查 buffer 是否被替换
let currentBuffer = null, currentView = null;
function getView() {
  if (currentBuffer !== wasm.memory.buffer) {
    currentBuffer = wasm.memory.buffer;
    currentView = new Uint8Array(currentBuffer);
  }
  return currentView;
}

Rust 侧的分配追踪

更精细的内存追踪可以在 Rust 侧实现——通过自定义全局分配器记录每次分配和释放。这种方法能精确回答"Rust 堆上此刻还挂着多少字节、多少次分配";要进一步回答"哪个分配点在泄漏",还得在超过阈值的分配里调一个导入的 JS 函数记下 new Error().stack(第 11 章 §11.17.2):

use std::alloc::{GlobalAlloc, Layout, System};
use std::sync::atomic::{AtomicUsize, Ordering};
use serde::Serialize;
use wasm_bindgen::prelude::*;

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 {
    // json_compatible:否则 json! 生成的对象会变成 JS Map,stats.allocated_bytes 取到 undefined
    serde_json::json!({
        "allocated_bytes": ALLOCATED.load(Ordering::Relaxed),
        "allocation_count": ALLOCATION_COUNT.load(Ordering::Relaxed),
    })
    .serialize(&serde_wasm_bindgen::Serializer::json_compatible())
    .unwrap()
}

这个方案的注意事项:自定义分配器本身有性能开销(每次分配/释放都有原子操作),不适合在生产热路径使用。只在调试内存泄漏时启用。一个更精细的方案是只在特定时间段启用追踪——通过一个 AtomicBool 开关控制是否记录分配信息:

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
    }
    // dealloc 同样要先判断开关;即便如此,开启前分配、开启后释放的块
    // 仍会让 ALLOCATED 往下减(fetch_sub 会回绕),所以只拿它看区间内的增量趋势
    // ...
}

#[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 可观测性架构总览

graph TD
  subgraph "WASM 模块内部"
    A["tracing crate<br/>(Span + Event)"] --> B["StructuredWasmLayer<br/>(自定义 Layer)"]
    C["panic::set_hook<br/>(panic 捕获)"] --> D["emit_structured_log<br/>(错误日志)"]
    E["TrackingAllocator<br/>(分配追踪)"] --> F["get_memory_stats<br/>(内存统计)"]
    G["performance::mark/measure<br/>(性能计时)"] --> H["Performance Timeline"]
  end
  subgraph "wasm-bindgen 边界"
    I["JsValue 传递<br/>(结构化日志)"]
    J["memory.buffer<br/>(线性内存)"]
  end
  B --> I
  D --> I
  F --> I
  J --> K["MemoryMonitor<br/>(JS 侧内存监控)"]
  subgraph "宿主侧(浏览器)"
    I --> L["__log_collector<br/>(日志收集)"]
    L --> M["DevTools Console"]
    L --> N["Grafana Loki"]
    H --> O["Chrome Performance Panel"]
    K --> P["内存泄漏告警"]
  end
  subgraph "宿主侧(服务器)"
    Q["宿主打点 + fuel 记账<br/>+ ResourceLimiter 钩子"] --> R["Prometheus Exporter"]
    R --> S["Grafana Dashboard"]
    Q --> T["OpenTelemetry Collector"]
    T --> U["Jaeger / Zipkin"]
  end
  style A fill:#6366f1,color:#fff
  style I fill:#f59e0b,color:#333
  style Q fill:#10b981,color:#333

18.10 SLO 定义与告警设计

可观测性的最终目的不是"看到数据"——而是在出问题时被及时叫醒。这需要明确的 SLO(Service Level Objective)和精确的告警规则,否则要么漏报(错过故障)要么误报(值班人员被告警淹没)。

18.10.1 WASM 工作负载的 SLO 维度

graph TD
  A["WASM SLO 维度"] --> B["可用性 SLI"]
  A --> C["延迟 SLI"]
  A --> D["资源 SLI"]
  A --> E["业务 SLI"]
  B --> B1["实例化成功率<br/>> 99.9%"]
  B --> B2["调用成功率(无 trap)<br/>> 99.95%"]
  C --> C1["P95 调用延迟<br/>< 100ms"]
  C --> C2["P99 调用延迟<br/>< 500ms"]
  C --> C3["WASM 加载时间<br/>P95 < 1s"]
  D --> D1["线性内存使用<br/>< 配置上限的 80%"]
  D --> D2["实例数<br/>< 配置上限的 80%"]
  E --> E1["业务功能完成率<br/>视场景定义"]
  style B fill:#10b981,color:#333
  style C fill:#6366f1,color:#fff
  style D fill:#f59e0b,color:#333
  style E fill:#dc2626,color:#fff

每条 SLI 都对应一个 SLO(例如"实例化成功率月度 > 99.9%"),SLO 决定 error budget——超出 budget 触发告警和回滚。

18.10.2 告警规则的分级

不是所有指标异常都该叫醒值班人员。三级告警体系:

级别 触发条件 通知渠道 响应 SLA
P0 紧急 服务不可用,影响所有用户 电话 + Slack + PagerDuty 5 分钟
P1 严重 SLO 违反,影响部分用户 Slack + 邮件 30 分钟
P2 关注 指标异常但未违反 SLO 邮件 工作时间内

WASM 特有的告警规则示例:

# 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 调用失败,应该只告警上游——下游的告警自动抑制:

inhibit_rules:
- source_match: { service: 'upstream-api', severity: 'P0' }
  target_match: { service: 'wasm-processor' }
  equal: ['cluster']

手段三:智能聚合。100 个 Pod 同时报同一个错误应该聚合为 1 条告警,附上"影响范围"。Alertmanager 的 group_by 配置:

route:
  group_by: ['alertname', 'service']
  group_wait: 30s
  group_interval: 5m

18.10.4 SLO 看板:从指标到决策

实战的 SLO 看板必须回答三个问题:

  1. 现在状态健康吗?(实时 SLI)
  2. 错误预算还剩多少?(窗口内的累计违反)
  3. 趋势在恶化吗?(与上周/上月对比)
graph LR
  A["实时 SLI<br/>(过去 5 分钟)"] --> D["SLO 决策"]
  B["错误预算<br/>(本月剩余)"] --> D
  C["趋势<br/>(7 天移动平均)"] --> D
  D --> E{"剩余预算 > 50%?"}
  E -->|是| F["继续发布新功能"]
  E -->|否| G{"剩余 > 0?"}
  G -->|是| H["冻结新功能<br/>专注稳定性"]
  G -->|否| I["立即回滚<br/>事故响应"]
  style F fill:#10b981,color:#333
  style H fill:#f59e0b,color:#333
  style I fill:#dc2626,color:#fff

错误预算是工程纪律——超预算就停止发布。这条规则比任何技术方案都重要:它强制工程团队为可靠性买单,而不是不断加新功能直到系统崩溃。

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 频率 采样时比较 memory.buffer 是否换成了新对象(JS 拦截不到 wasm 内部的 memory.grow 指令)
错误 trap / panic 总数 error boundary 捕获

18.11.2 RUM 实现样板

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 = {
                // body 的下载和流式编译是重叠进行的,都算在第二段里;
                // 纯下载耗时看 performance.getEntriesByType('resource')
                responseHeadersMs: t1 - t0,
                downloadCompileInstantiateMs: 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');

// beforeunload/unload 在移动端经常不触发;MDN 建议在页面转入 hidden 时发送
document.addEventListener('visibilitychange', () => {
    if (document.visibilityState === 'hidden') rum.flush();
});
setInterval(() => rum.flush(), 30000);

navigator.sendBeacon 是关键——它把请求交给浏览器在后台发出,页面随后被关掉也不会被取消(普通 fetch 在页面卸载时常被取消)。触发时机则选 visibilitychange 转入 hidden,而不是 beforeunload(MDN sendBeacon 文档的建议)。

18.11.3 采样与隐私

如果每次调用都记一条,一个活跃用户每分钟就可能产生几百条数据,再乘上 DAU 和在线时长,量级很快过亿。全量采集成本极高。

采样策略:

数据类型 采样率 理由
加载指标 100% 频次低,价值高
调用延迟 1-10% 量大,统计意义足够
错误日志 100% 错误必须捕获
慢调用(> P99) 100% 性能问题诊断的关键证据

隐私合规:RUM 数据不能包含 PII(个人身份信息)。userAgent 已经接近 PII(可与其他信号组合做指纹识别)——在欧盟,这类数据可能被认定为个人数据,采集前要评估 GDPR 下的合法依据(是否需要用户同意),拿不准就按需要同意处理。

合规做法:把 userAgent 在客户端处理,只发送类别(mobile-android-low-end / desktop-modern):

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 数据汇总后驱动三类决策:

flowchart LR
  A["RUM 原始数据"] --> B["实时指标<br/>(error rate, P95)"]
  A --> C["分布分析<br/>(分设备/网络)"]
  A --> D["性能回归<br/>(对比上一版本)"]
  B --> E["告警 / 实时大盘"]
  C --> F["产品决策<br/>(支持哪些设备)"]
  D --> G["发布决策<br/>(回滚 / 推进)"]
  style E fill:#dc2626,color:#fff
  style F fill:#6366f1,color:#fff
  style G fill:#10b981,color:#333

性能回归检测特别关键:每次发版前对比新旧版本的 P95,如果 P95 恶化 > 10%,自动阻止 100% 切流。这是从生产事故中学到的教训——开发机性能正常的版本,可能因为某个 polyfill 或 bundle 体积变化在低端设备上崩溃。

18.12 可观测性数据的成本控制与采样

可观测性最容易被忽视的工程问题:数据生产成本。一个高流量 WASM 服务每天产出几 TB 日志、几亿条 metrics、上亿条 trace span——全量采集的存储和传输成本可能比业务计算本身还高。理性的成本控制需要明确的采样策略。

18.12.1 三类数据的成本特征

graph TD
  A["可观测性数据"] --> B["Logs"]
  A --> C["Metrics"]
  A --> D["Traces"]
  B --> B1["按字节计费<br/>体积正比于请求数 × 每请求日志条数"]
  C --> C1["按时间序列条数计费<br/>与请求数无关,只与标签组合数有关"]
  D --> D1["按 span 数计费<br/>体积正比于请求数 × 每请求 span 数"]
  style B fill:#dc2626,color:#fff
  style C fill:#10b981,color:#333
  style D fill:#f59e0b,color:#333

计费维度的差异决定了优化手段完全不同,这比任何单价表都重要:

数据 主流计费维度 成本随什么增长 对应的优化手段
Logs 摄入字节数 + 保留期 请求量 × 每请求日志条数 × 每条大小 采样(§18.12.2)、降低日志级别、砍冗余字段
Metrics 活跃 series 数 标签组合数,与请求量无关 控制基数(§18.12.3),绝不把用户 ID 当标签
Traces span 数 + 保留期 请求量 × 每请求 span 数 尾部采样(§18.12.4),合并过细的 span

最反直觉的是 metrics 这一行:它的成本和你有多少请求几乎无关,一个 label 用错就能让账单跳几个数量级;而 logs 和 traces 是老老实实按量线性增长的。各家 SaaS 的具体单价差异很大且变动频繁,算账时以你实际用的那家的当期定价页为准。

结论不变:全量采集在有规模的服务上不可持续——必须采样。

18.12.2 Logs 的采样策略

策略一:按级别采样——错误日志 100%,info 日志 1%:

fn should_log(level: Level, sample_rate: f64) -> bool {
    // 注意 tracing 的 Level 比较方向:越详细越"大"(TRACE > DEBUG > INFO > WARN > ERROR),
    // 写成 level >= Level::ERROR 会对所有级别都成立,所以这里用相等判断
    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%:

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%:

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% 采样。生产环境的典型配置:

flowchart LR
  A["请求"] --> B{"错误?"}
  B -->|是| Z["100% 采样"]
  B -->|否| C{"慢? > 1s"}
  C -->|是| Z
  C -->|否| D{"VIP 用户?"}
  D -->|是| Z
  D -->|否| E["1% 随机采样"]
  style Z fill:#10b981,color:#333
  style E fill:#f59e0b,color:#333

18.12.3 Metrics 的基数控制

Metrics 成本主要来自高基数标签(cardinality)——每个唯一的 label 组合算一个 series。如果 metric 有 5 个 label 各 100 个值,最大 100^5 = 100 亿 series。

反模式:

// 反模式:把用户 ID 作为 label
metrics::counter!("api_requests_total", "user_id" => user_id.to_string()).increment(1);
// 100 万用户 = 100 万 series,存储成本爆炸

正确做法:

// 把高基数的维度移到 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 在内存中:

flowchart LR
  A["请求开始"] --> B["创建 trace"]
  B --> C["所有 span 暂存"]
  C --> D["请求结束"]
  D --> E{"trace 有错误? <br/>慢? <br/>异常 pattern?"}
  E -->|是| F["发送到后端"]
  E -->|否| G["丢弃 99%"]
  style F fill:#10b981,color:#333
  style G fill:#dc2626,color:#fff

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 成本监控本身

可观测性数据本身需要可观测——监控成本指标:

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 清单:

flowchart TD
  A["新增可观测性数据"] --> B["月成本估算 = 体积 × 单价"]
  B --> C["每月使用频率?"]
  C --> D{"每月被查询 > 5 次?"}
  D -->|是| E["保留"]
  D -->|否| F{"是合规必需?"}
  F -->|是| G["保留 + 标记合规"]
  F -->|否| H["降低保留期或采样率"]
  style E fill:#10b981,color:#333
  style G fill:#6366f1,color:#fff
  style H fill:#dc2626,color:#fff

每个月做一次 review——把"可能有用但从未被查的数据"识别出来,提高其采样率或缩短保留期。这套清单能把可观测性月账单控制在合理范围内。

18.13 合规与隐私:可观测性数据的边界

可观测性数据天然包含敏感信息——用户 ID、IP、查询内容、错误堆栈中的内存数据。处理不当会触犯 GDPR、HIPAA、SOC 2 等合规要求。生产 WASM 部署的可观测性必须从设计开始考虑数据边界。

18.13.1 数据敏感性分类

graph TD
  A["可观测性数据"] --> B["公开数据"]
  A --> C["伪 PII"]
  A --> D["真 PII"]
  A --> E["敏感数据"]
  B --> B1["错误码 / 函数名 / 性能指标"]
  C --> C1["UA / IP / cookies / 设备指纹"]
  D --> D1["用户名 / 邮箱 / 真实姓名"]
  E --> E1["密码 / 令牌 / 银行卡 / 健康记录"]
  B --> B2["合规风险:低"]
  C --> C2["合规风险:中(GDPR 视为 PII)"]
  D --> D2["合规风险:高<br/>需要明确同意"]
  E --> E2["合规风险:极高<br/>禁止采集"]
  style B fill:#10b981,color:#333
  style C fill:#f59e0b,color:#333
  style D fill:#dc2626,color:#fff
  style E fill:#dc2626,color:#fff

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 数据脱敏与匿名化

实战手段:

// 反模式:直接日志原值
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 地址截断:

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 会把 panic 消息连同 JS 栈一起输出。栈里只有函数名和偏移,但 panic 消息可能带变量值——比如对 Err(e) 调 unwrap(),消息里就是 e 的 Debug 输出。生产环境必须过滤:

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();
        // PanicHookInfo::payload_as_str(Rust 1.91 起稳定,`library/std/src/panic.rs:125`)同时覆盖 &'static str 与格式化后的 String 两种 payload;
        // 只 downcast_ref::<&str>() 的话,panic!("{x}") 这类格式化消息会全落到 "unknown"
        let msg = info.payload_as_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 要求"被遗忘的权利"——用户请求删除时必须能彻底清理:

flowchart LR
  A["用户请求删除"] --> B["1. 标记账户为 deleted"]
  B --> C["2. 启动批处理任务"]
  C --> D["3. 删除日志中的相关记录"]
  D --> E["4. 删除 metrics 中的 user_hash 系列"]
  E --> F["5. 通知所有数据下游"]
  F --> G["6. 30 天后审计验证"]
  style B fill:#6366f1,color:#fff
  style G fill:#10b981,color:#333

工程实现的关键:所有可观测性数据必须用一致的用户 hash 标识——这样删除时可以一次性查找所有相关记录。如果不同系统用不同 ID,删除会有遗漏。

18.13.6 跨境数据传输

欧盟个人数据出境需要合法的传输机制——这影响 SaaS 监控的选型:

选型时要核对的是该供应商是否提供欧盟境内的数据驻留区域,以及你的账号实际开在哪个区域——同一家 SaaS 往往既有美国区也有欧盟区,开错区域比选错供应商更常见。各家的区域覆盖会随时间变化,务必以供应商官网的 data residency / regions 页面为准,不要引用任何第三方整理的表格。

如果业务有欧盟用户,最省心的是选欧盟区域部署的服务;要用美国区域,就得先确认传输机制和合同条款到位,而不是图便宜直接开。自建(Loki/Tempo/Mimir 部署在欧盟机房)是绕开这个问题的另一条路,代价是运维成本。

18.13.7 合规审查 checklist

flowchart TD
  A["可观测性合规审查"] --> B["1. 数据敏感性分类完成?"]
  A --> C["2. PII 都脱敏 / 哈希?"]
  A --> D["3. 错误堆栈做过滤?"]
  A --> E["4. 保留期符合规定?"]
  A --> F["5. 用户删除流程完整?"]
  A --> G["6. SaaS 区域合规?"]
  A --> H["7. 隐私声明更新?"]
  style A fill:#6366f1,color:#fff

每条都对应一项法规要求——遗漏任何一条都可能触发监管处罚(GDPR 第 83 条第 5 款的罚款上限是 2000 万欧元或上一财年全球年营业额的 4%,取高者)。把这套审查嵌入项目立项和上线流程,让合规成为默认而非事后补救。

18.14 WASM 事故响应 Runbook

可观测性的最终目的是支持事故响应——出问题时能在最短时间内定位、缓解、修复。这套 runbook 是"半夜被 paged 时该怎么办"的标准答案,适用于生产 WASM 服务。

18.14.1 事故响应的标准流程

flowchart LR
  A["告警触发"] --> B["1. 确认<br/>是否真实事故"]
  B --> C["2. 缓解<br/>恢复服务"]
  C --> D["3. 诊断<br/>定位根因"]
  D --> E["4. 修复<br/>根本性解决"]
  E --> F["5. 复盘<br/>避免重现"]
  style B fill:#f59e0b,color:#333
  style C fill:#dc2626,color:#fff
  style F fill:#10b981,color:#333

每阶段有清晰目标:

  • 确认(< 5 分钟):是误报还是真问题?
  • 缓解(< 30 分钟):用户体验先恢复
  • 诊断(30 分钟 - 2 小时):找到具体原因
  • 修复(视严重性):写代码修
  • 复盘(事后 1 周内):写 postmortem

18.14.2 WASM 特有的事故类型

graph TD
  A["WASM 事故类型"] --> B["实例化失败"]
  A --> C["Trap / panic"]
  A --> D["内存增长"]
  A --> E["性能回归"]
  A --> F["跨边界数据错误"]
  B --> B1["浏览器版本不支持<br/>CSP 配置错<br/>CORS 失败"]
  C --> C1["Rust unwrap<br/>越界访问<br/>除零"]
  D --> D1["Vec 无界增长<br/>Closure 泄漏"]
  E --> E1["新版本部署后<br/>P95 翻倍"]
  F --> F1["JS 传错类型<br/>WASM 解析失败"]
  style B fill:#dc2626,color:#fff
  style C fill:#dc2626,color:#fff
  style D fill:#f59e0b,color:#333
  style E fill:#f59e0b,color:#333

18.14.3 事故场景一:实例化失败率激增

症状:监控显示 wasm_instantiate_failures_total 突然飙升。

响应步骤:

flowchart TD
  A["实例化失败率激增"] --> B["1. 看 user agent 分布"]
  B --> C{"特定浏览器?"}
  C -->|是| D["特性检测可能失败"]
  C -->|否| E["看错误信息分布"]
  E --> F{"CSP 错?"}
  F -->|是| G["回滚 CSP 头变更"]
  E --> H{"CORS 错?"}
  H -->|是| I["检查 CDN 头配置"]
  D --> J["立即开特定浏览器降级"]
  G --> K["缓解完成"]
  I --> K
  style K fill:#10b981,color:#333

诊断关键:WASM 模块本身往往是好的——问题在加载链路(CSP/CORS/CDN/网络)。这是 WASM 与传统服务的关键差异。

18.14.4 事故场景二:trap 率突增

症状:监控显示 wasm_trap_total 速率上升。

响应步骤:

flowchart TD
  A["Trap 率突增"] --> B["1. 看 trap 类型分布"]
  B --> C{"unreachable?"}
  C -->|是| D["panic!看 stack trace"]
  B --> E{"out of bounds?"}
  E -->|是| F["内存访问越界"]
  B --> G{"div by zero?"}
  G -->|是| H["除零未保护"]
  D --> I["紧急 hotfix 加 expect/Result"]
  F --> J["检查最近 PR 的 unsafe 代码"]
  H --> K["加 if 检查"]
  I --> L["回滚到上一版本"]
  J --> L
  K --> L
  style L fill:#10b981,color:#333

缓解优先:先回滚到没问题的版本,再慢慢诊断。生产事故不允许"边查边修"。

18.14.5 事故场景三:内存持续增长

症状:wasm_linear_memory_bytes 缓慢但稳定增长,最终触发 OOM。

响应步骤:

flowchart TD
  A["内存持续增长"] --> B["1. 是否最近上线?"]
  B --> C{"是"}
  C --> D["回滚版本"]
  B --> E{"否"}
  E --> F["定期重启实例缓解"]
  F --> G["2. 打开 Rust 侧计数分配器<br/>(#[global_allocator])"]
  G --> H["3. 大分配处记 JS 栈<br/>找出没释放的分配点"]
  H --> I["4. fix 闭包/全局缓存"]
  D --> J["缓解"]
  I --> J
  style J fill:#10b981,color:#333

WASM 内存泄漏诊断比原生难——没有 valgrind。在 JS 里覆写 __wbindgen_malloc / __wbindgen_free 来包装是行不通的:导出对象是冻结的,赋值直接抛 TypeError,而且 Rust 内部的 Vec/String 分配根本不经过这两个导出(第 11 章 §11.9.2 实测)。可行的做法是 §18.8 那种 Rust 侧计数的 GlobalAlloc:先用开关限定时间窗看 Rust 堆的净增量,再对超过阈值的分配调一个导入的 JS 函数记下 new Error().stack(§11.17.2),就能定位到具体调用栈。

18.14.6 事故场景四:性能回归

症状:新版本部署后 P95 翻倍。

响应步骤:

flowchart TD
  A["性能回归"] --> B["1. 立即金丝雀回滚<br/>从 100% 切回 0%"]
  B --> C["2. 对比新旧版本<br/>perf flamegraph"]
  C --> D{"瓶颈?"}
  D --> E["WASM 内函数变慢"]
  D --> F["跨边界次数翻倍"]
  D --> G["内存频繁 grow"]
  E --> H["wasm-tools 看是否启用 SIMD"]
  F --> I["看 PR 是否改了 API 形态"]
  G --> J["看是否预分配被改"]
  style B fill:#dc2626,color:#fff

性能回归最快的缓解永远是回滚——绝大多数"性能 bug"短期不可能现场修复。

18.14.7 事故场景五:跨边界数据错误

症状:用户报错"参数错误"或 WASM 收到意外数据。

响应步骤:

flowchart TD
  A["数据错误"] --> B["1. 看具体错误信息"]
  B --> C{"哪一侧报错?"}
  C --> D["JS 侧:wasm-bindgen 类型不匹配"]
  C --> E["WASM 侧:parse/decode 失败"]
  D --> F["看 .d.ts 是否最新"]
  E --> G["看输入采样:JS 传了什么"]
  F --> H["重新发布 npm 包"]
  G --> I["增加 input validation"]
  style F fill:#f59e0b,color:#333
  style G fill:#f59e0b,color:#333

最常见原因:JS 端使用了过期的 .d.ts,调用时传了 WASM 不再支持的格式。

18.14.8 Runbook 模板

每个生产 WASM 服务应有自己的 runbook。模板要素:

graph TD
  A["WASM Runbook 必备"] --> B["1. 告警规则与阈值"]
  A --> C["2. 监控看板链接"]
  A --> D["3. 回滚操作步骤"]
  A --> E["4. 联系人列表(值班/上游)"]
  A --> F["5. 5 类典型事故 + 处理流程"]
  A --> G["6. 缓解与修复区分"]
  A --> H["7. 复盘模板"]
  style A fill:#6366f1,color:#fff

把 runbook 写成 markdown 进 wiki 或代码仓库——值班人员能在凌晨 3 点照着做,恢复时间不再取决于当晚是谁值班。

18.15 多租户场景的可观测性隔离

SaaS、FaaS、插件平台等多租户 WASM 场景,可观测性数据必须按租户隔离——一个租户不能看到其他租户的日志/指标。这是合规要求也是工程挑战。

18.15.1 多租户隔离的层级

graph TD
  A["多租户可观测性隔离"] --> B["数据采集层"]
  A --> C["存储层"]
  A --> D["查询层"]
  B --> B1["每租户独立 tags<br/>tenant_id 必须打"]
  C --> C1["分租户存储<br/>或共享 + 严格过滤"]
  D --> D1["租户只能查自己的数据"]
  style B fill:#10b981,color:#333
  style C fill:#6366f1,color:#fff
  style D fill:#dc2626,color:#fff

每层都有失败模式:

  • 采集:忘记打 tenant_id 标签 → 数据无法分聚
  • 存储:共享存储未做隔离 → 跨租户数据泄漏
  • 查询:权限错配 → 租户 A 看到租户 B 的数据

18.15.2 数据采集的隔离实现

// 包装所有 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 存储层的隔离方案

graph TD
  A["多租户存储隔离"] --> B["完全隔离<br/>每租户独立索引"]
  A --> C["逻辑隔离<br/>共享存储 + tag 过滤"]
  A --> D["分组隔离<br/>大客户独立,小客户共享"]
  B --> B1["✓ 强隔离<br/>✗ 成本高(N 倍)"]
  C --> C1["✓ 成本低<br/>✗ 查询安全要求高"]
  D --> D1["折衷方案<br/>实战常用"]
  style D fill:#10b981,color:#333
方案 成本 隔离强度 适用
完全隔离 高(N×) 最强 监管严格场景
逻辑隔离 低 依赖查询权限 大多数 SaaS
分组隔离 中 中 大客户优先

多数 SaaS 选逻辑隔离——成本最低,配合严格的查询权限控制能达到合规要求。

18.15.4 查询权限的实现

// 查询接口必须验证 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 过滤条件:要解析 PromQL,给每个指标选择器加上
    //    tenant="<id>" 匹配(prom-label-proxy 就是做这个的);
    //    直接字符串拼接既不是合法 PromQL,也拦不住 `or` 之类的越权写法
    let filtered_query = enforce_tenant_label(query, tenant_id)?;

    // 3. 执行查询
    prometheus::query(&filtered_query).await
}

关键:永远不让用户提交的 query 直接执行——必须由后端注入 tenant 过滤。否则用户能用 PromQL 查到其他租户数据。

18.15.5 跨租户全局视图

平台运营者需要看跨租户的全局指标——但要小心隔离边界:

flowchart TD
  A["管理员看板"] --> B["1. 聚合指标(脱敏)"]
  A --> C["2. 租户排行(脱敏)"]
  A --> D["3. 异常告警(含租户 ID)"]
  B --> B1["不显示租户具体业务数据"]
  C --> C1["显示 tenant-{hash}<br/>不显示真实名"]
  D --> D1["运营有权限处理"]
  style A fill:#6366f1,color:#fff
  style D fill:#10b981,color:#333

管理员看板应该:

  • 显示总量、平均、分位数等聚合数据
  • 不显示个别租户的具体业务(如 SQL 查询内容)
  • 紧急告警可以显示具体租户,但有审计日志

18.15.6 资源隔离的可观测性

多租户除数据隔离,还需监控资源使用隔离:

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.7 配额与监控的协同

flowchart LR
  A["监控数据"] --> B["实时计算资源使用"]
  B --> C{"超过配额?"}
  C -->|是| D["1. 限流"]
  C -->|否| E["正常服务"]
  D --> F["2. 通知租户"]
  D --> G["3. 触发告警"]
  style D fill:#dc2626,color:#fff

监控不只是"看"——还要驱动配额执行。这种"监控+控制"的反馈环让多租户平台稳定。

18.15.8 合规与租户审计

每个租户应该能看到自己的可观测性数据:

graph TD
  A["租户的合规需求"] --> B["1. 看自己的日志"]
  A --> C["2. 导出自己的数据"]
  A --> D["3. 删除自己的数据(GDPR)"]
  B --> B1["租户面板里展示"]
  C --> C1["API 提供导出"]
  D --> D1["删除时清理所有 tenant=X 的数据"]
  style A fill:#10b981,color:#333

这些是 SaaS 平台的基础合规要求——必须从一开始就设计进去,不能后期补救。

18.16 WASM 可观测性的成熟度模型

可观测性不是"有/无"——而是有不同成熟度。这一节定义 5 级成熟度模型,让团队能客观评估自己的可观测性水平,规划下一步投入。

18.16.1 5 级成熟度框架

graph LR
  A["第 0 级<br/>盲目"] --> B["第 1 级<br/>基础"]
  B --> C["第 2 级<br/>可观测"]
  C --> D["第 3 级<br/>主动"]
  D --> E["第 4 级<br/>智能"]
  A --> A1["仅 console.log"]
  B --> B1["错误监控"]
  C --> C1["完整 metrics"]
  D --> D1["分布式追踪"]
  E --> E1["AI 辅助"]
  style A fill:#dc2626,color:#fff
  style E fill:#10b981,color:#333

每级有明确特征——团队可以对照评估自己的位置。

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 模型识别异常模式
  • 故障自动定位(把候选根因缩小到几条,而不是让人从零翻看板)
  • 容量需求预测
  • 部分场景自动修复

典型工具:Datadog Watchdog / New Relic AI

18.16.7 定位自己在哪一级,以及该走到哪一级

判定不需要问卷,一个问题就够——上次事故你是怎么发现的:

flowchart TD
  A["你的可观测性级别?"] --> B{"事故是怎么发现的?"}
  B --> C["用户反馈 → 第 0 级"]
  B --> D["告警触发 → 第 1-2 级"]
  B --> E["监控趋势预警 → 第 3 级"]
  B --> F["AI 主动告知 → 第 4 级"]
  style C fill:#dc2626,color:#fff
  style F fill:#10b981,color:#333

升级的投入逐级变重:0 → 1 接入 Sentry 一类的错误监控最轻;1 → 2 要铺 metrics 和看板;2 → 3 铺 OpenTelemetry 全链路要动每个服务;3 → 4 建异常检测平台最重。具体工期取决于团队和系统规模,没有通用数字。跨级跳通常失败——「我们 0 级直接上 AI」不现实,因为高一级的能力都建立在低一级的数据之上。

但不是所有业务都该往上爬:内部工具到第 1 级就够,生产 SaaS 落在第 2–3 级,核心基础设施才需要第 3–4 级。明确目标是为了避免过度投入。

每级升级带来的能力变化(而不是某个百分比——具体收益完全取决于你的事故基线,任何通用数字都是编的):

升级 主要投入 换来的能力
0 → 1 接入错误监控 从"用户告诉你出事了"变成"系统告诉你出事了"
1 → 2 铺 metrics + 结构化日志 + 看板 从"知道出事了"变成"知道在哪一层出事"
2 → 3 全链路追踪 + SLO + runbook 从"事后排查"变成"预算耗尽前就介入"
3 → 4 异常检测 / 自动根因 从"人看看板"变成"系统给候选根因"

前两级的边际收益最陡(花得少、变化最明显),第 4 级只有在规模足够大、告警量本身已成为负担时才划算。多数项目应该以第 2–3 级为目标。

最后一点和工具无关:可观测性能不能维持在某一级,取决于几条日常约定——不可观测的代码不合并、事故必复盘、看板对全员透明、把它当作和功能开发同等重要的投入。这套成熟度模型本身的最大用处,其实是沟通工具:让团队和管理层对「可观测性做到什么程度」有同一把尺子,而不是各说各的。

18.17 可观测性检查清单

维度 问题 方案 章节
日志 WASM 内部发生了什么? wasm-tracing(tracing-wasm 的维护分叉)/ tracing-web,或自定义 StructuredWasmLayer → JS console/日志服务 18.2-18.3
结构化日志 日志能否被机器解析? serde + serde-wasm-bindgen 构造 JsValue 传给 JS(JsValue::from_serde 已废弃) 18.3
指标 实例数量、内存使用、调用频率? 宿主打点 + Store::get_fuel 差值 + ResourceLimiter 钩子 → Prometheus(Wasmtime 没有通用的内置 metrics API,只有池化分配器的占用数据) 18.4
性能 哪个函数是热点? 浏览器:DevTools Performance(+ DWARF 扩展);服务器:wasmtime run --profile=perfmap/jitdump/vtune 配外部 profiler,或 --profile=guest / GuestProfiler 进程内采样;辅以 performance.mark/measure 分段计时 18.5
分布式追踪 请求经过了哪些组件? WIT tracing 接口 + OpenTelemetry + trace context 传播 18.6
错误 为什么 trap? panic::set_hook(浏览器唯一可靠手段)+ 宿主侧 downcast_ref::<Trap>() / WasmBacktrace + 结构化错误日志 18.7
内存 是否泄漏? memory.buffer.byteLength 增长趋势 + 自定义分配追踪 18.8

这份检查清单应该嵌入到项目的 CI/CD 流程中——每次发布前确认每个维度都有覆盖。与《Tokio 源码深度解析》专栏第 17 章(可观测性)中对异步任务监控的思路类似,可观测性不是"加几个日志"的事后补丁,而是从架构设计阶段就需要考虑的核心需求。一个没有可观测性的 WASM 模块在生产环境中就是一个黑盒——出了问题只能盲人摸象。

下一章用十个生产案例展示 WASM 在真实场景中的表现——从图像处理到密码学,每个案例都离不开本章讨论的可观测性基础设施。