很多人把 Meter 当成"新版 EventSource",把 ActivitySource 当成"新版 DiagnosticSource",然后在生产环境里发现:指标查不到、链路断了、开销还不小。这三个 API 解决的是三个不同维度的问题,混用一定会踩坑。
结论先行
- **
Meter/Instrument(System.Diagnostics.Metrics)只负责"数",不负责"事"。**它是聚合器,适合计数器、直方图、当前值;不适合承载"这次请求发生了什么"。 - **
ActivitySource负责"事"和"链路"。**一个Activity就是一段有开始/结束、有父子关系的工作单元,是分布式追踪的唯一载体。 - **
EventSource是给 EventPipe / ETW / dotnet-trace 用的诊断通道。**排查偶发问题(GC、线程池、异常栈)时用它,做业务监控别用它。 - **三者可以共存,但不要互相替代。**典型服务:
Meter出 QPS 与延迟直方图,ActivitySource出跨服务调用链,EventSource在需要深挖时按需打开。 - 它们全都是"零监听者近乎零成本"的设计 :没有
MeterListener/ActivityListener订阅时,库里的打点基本就是几次分支判断。真正的开销来自你自己写错的参数求值和标签基数。
机制:三套 API 各自的边界
Meter:Instrument 才是主角
Meter 本身只是个命名容器,真正做事的是它创建的 Instrument:
| Instrument | 语义 | 典型用途 |
|---|---|---|
Counter<T> |
只增不减的累计量 | 请求数、错误数、发送字节数 |
Histogram<T> |
分布,落进桶 | 延迟、载荷大小 |
UpDownCounter<T> |
可增可减的瞬时量 | 队列长度、并发连接数 |
ObservableGauge<T> |
采集时回调取值 | 内存占用、缓存条目数 |
前三个是同步打点(你主动 Add / Record),最后一个是异步观测(采集器每隔一段时间回调你的委托)。这个区别很关键:同步 Instrument 由你的代码决定何时写,异步 Instrument 由采集器决定何时读。
写入路径是:Instrument.Add → MeterListener.SetMeasurementEventCallback 注册的回调 → 导出器(OpenTelemetry、Prometheus 抓取端点等)。中间没有队列、没有后台线程,回调是同步跑在你的调用线程上的。所以导出器的耗时会计入你的请求延迟------这是很多人"加了监控反而变慢"的真正原因。
IMeterFactory:别自己 new Meter
.NET 8 起 DI 容器提供 IMeterFactory,注册方式是一行 services.AddMetrics()。它做两件你自己做不到的事:按名字缓存 Meter 实例,以及在容器释放时统一 Dispose。
自己 new Meter("MyApp", "1.0") 放在静态字段里通常没问题;但如果放在服务构造函数里,每个实例一个 Meter,监听器会被反复注册、Instrument 数量随请求数增长,最终表现为内存缓慢上涨和标签重复。这是最容易被忽略的静默泄漏。
ActivitySource:StartActivity 可能返回 null
csharp
using System.Diagnostics;
static readonly ActivitySource Source = new("MyApp.Orders", "1.0");
public async Task<Order> LoadAsync(int id, CancellationToken ct)
{
using var activity = Source.StartActivity("order.load", ActivityKind.Internal);
activity?.SetTag("order.id", id);
var order = await _repo.GetAsync(id, ct);
if (activity?.IsAllDataRequested == true)
{
activity.SetTag("order.items", order.Items.Count);
}
return order;
}
StartActivity 在没有监听者、或监听者 ShouldListenTo 返回 false、或采样判定为 drop 时返回 null。所以所有后续操作必须能接受 activity 为 null。
IsAllDataRequested 与 Recorded 是两回事:前者表示"监听者想要全部细节",后者表示"这条 trace 被采样保留"。需要付出较高代价才能拿到的标签,用 IsAllDataRequested 守卫;纯粹的轻量标签直接写。
跨进程传播靠 W3C traceparent 头,由 DistributedContextPropagator.Current 负责注入与提取,HTTP 客户端和 ASP.NET Core 已默认接好,不需要手写。
四个容易静默失效的坑
**1. ?. 挡不住实参求值。**下面这行看着很安全,实际上 BuildDetail() 一定会执行:
csharp
activity?.SetTag("payload", BuildDetail()); // BuildDetail 照样被调用
?. 只短路了方法调用,实参在调用前就已经算完。要真正省掉开销,必须写成 if (activity?.IsAllDataRequested == true)。
**2. 直方图的默认桶是毫秒量级。**默认边界大致按 0、5、10、25、50... 这类数值递增设计。如果你的操作是微秒级的(内存缓存命中、纯计算),几乎所有样本都会落进最低那一个桶,你得到的 P99 就是一个毫无信息量的常量。给 Histogram 显式指定 unit 和自定义桶,或者干脆换用 Counter 统计分档命中次数。
**3. 标签基数爆炸。**把 userId、完整 URL、异常消息塞进标签,是监控系统被打爆的头号原因。每多一个唯一标签值就多一条时间序列,而这条序列会一直存在。规则很简单:标签值必须来自有限枚举集合;无限集合用日志或 trace 属性,不要用指标标签。
4. 观测回调里抛异常会静默吞掉。 CreateObservableGauge 传进去的委托在采集线程上执行,异常不会被你的请求上下文捕获,表现是"这个指标时有时无"。回调内部必须自己 try/catch 并返回一个明确的兜底值。
边界与不适用场景
- **不要用
Meter记录单次事件。**指标是聚合的,丢了就没了;需要事后追查某一次请求,只有 trace 和日志能用。 - **不要用
Activity统计总量。**采样率一开,计数就不准了。计数用Counter。 - **不要为了"以后可能要看"就全量打点。**每个 Instrument 都会增加导出器负担和后端成本,没有告警或看板消费方的指标就该删掉。
- 库作者特别注意 :不要在库内部初始化导出器或注册监听器,只定义
Meter/ActivitySource并公开名字,让宿主决定订阅什么。硬编码导出是侵入式设计。
一句话选型
问自己"我想知道什么":
- 想知道多少 / 多快 / 多大 →
Meter+Counter/Histogram。 - 想知道这一次请求经过了哪些环节、慢在哪一跳 →
ActivitySource。 - 想知道运行时内部发生了什么(GC、JIT、锁竞争、异常) →
EventSource+dotnet-trace/dotnet-counters。
三者叠加才是完整的可观测性;任何单一 API 想包打天下,最后都会变成"数据有了,但答不上问题"。