首页
/ ASP.NET Core 事件源接入指南:EventSource、EventCounter 埋点规范与自动化测试实践

ASP.NET Core 事件源接入指南:EventSource、EventCounter 埋点规范与自动化测试实践

2026-09-08 11:10:06作者:尤峻淳Whitney

EventSource 与 EventCounter 是 .NET 生态中面向生产可观测性的底层基础设施:前者以 ETW/manifest 机制发布结构化事件,后者以自描述计数器的形式输出随时间聚合的指标。本文以 ASP.NET Core 仓库的官方开发规范文档 docs/EventSourceAndCounters.md 为核心骨架,系统讲解在 ASP.NET Core 类库中新增 EventSource/EventCounter 追踪时应遵循的事件命名模式、代码风格、标准实现范式,并延伸到仓库内置的 Microsoft.AspNetCore.InternalTesting.Tracing 测试设施,覆盖 Event ID 一致性校验与事件功能测试两套自动化验证方案。读完本文,你将掌握一套"可照抄"的埋点模板,以及保证它与运行时行为一致、不会随合并冲突悄悄腐化的测试套路。

适用背景:为什么 ASP.NET Core 类库需要自带 EventSource

ASP.NET Core 的大量核心组件(如 Kestrel、SignalR、认证中间件等)并不是在框架内部为每个功能预埋一套统一的监控 API,而是遵循"每一个库自己定义自己的 EventSource"的约定。这样做的原因在于:

  • EventSource 由 .NET 运行时原生支持,可通过 PerfView、dotnet-trace、Windows ETW 等工具按名称订阅,无需应用代码改动;
  • 事件以"二进制 + manifest 自描述"方式发布,payload 只允许基元类型,便于跨进程/跨平台收集;
  • EventCounter 是计数器式指标通道,仅在监听器以 EventCounterIntervalSec 参数启用时才真正触发,天然适合低频采样聚合。

因此,"给库加可观测性"的正确姿势,是像写日志一样在每个关键路径上写事件——文档开篇就明确给出总原则:所有加了 EventSource 追踪的地方,同时也要加 ILogger 追踪(除非有充分理由不这么做),最低可以用 Trace 级别兜底。EventSource 面向工具化诊断,ILogger 面向人类可读的应用日志,二者互为补充而非替代。

本文后续的规范与示例来自 ASP.NET Core 仓库的工程实践(文档即规范来源),并有 src/Servers/Kestrel/Core/src/Internal/Infrastructure/KestrelEventSource.cs 这样的生产级实现可对照。

预备知识

按照文档约定,在动手前应对以下两个概念有基本了解:

  1. EventSource 的事件发布机制[Event] 特性标注的方法如何被生成 ETW manifest,WriteEvent(id, ...) 如何与特性中的 eventId 对应。
  2. EventCounter 的工作原理:计数器何时被启用(取决于监听器传入的 EventCounterIntervalSec)、WriteMetric 写入的原始值如何被聚合成多个统计量。

历史踩坑提醒(源自仓库文档):查看 EventCounter 需要 .NET Core 2.0.3 及以上版本——2.0.0 RTM 中计数器在运行时是损坏的。用 2.0 RTM 编译没有问题,只是计数器实际上不会被触发。

事件模式(Event Patterns)

文档汇总了一套团队沉淀的事件设计规范,逐条拆解如下,这些规则直接决定了事件在 PerfView 等工具里的可读性与可关联性:

结构层面

  • Start/Stop 事件必须共享至少一个 payload 值用于关联配对,例如 Request ID、Request Path、Action Name 等。
  • Error 事件一律使用 EventLevel.Error 级别
  • Stop 事件必须携带一个 double durationInMilliseconds payload,表示 Start 到 End 之间的毫秒耗时。
  • 计时使用 ValueStopwatch(来自仓库 src/Shared/ValueStopwatch/ValueStopwatch.cs)。它是一个简单的 struct(本质是对一个 long 时间戳的封装),避免计时产生堆分配。
  • payload 只能是基元类型,富对象必须在写入前展开,或者通过一个 [NonEvent] 包装方法来展开。
  • 异常要展开为三个 string payloadexceptionType(取 .GetType().AssemblyQualifiedName)、exceptionMessage(取 .Message)、exceptionDetails(取 .ToString())。
  • payload 命名要描述性强,因为会原样展示在 PerfView 等事件查看工具中——例如用 durationInMilliseconds 而不是 duration

命名模式

  • 操作开始事件后缀用 Start;不要用 BeginStarted
  • 操作结束事件后缀用 Stop;不要用 StoppedEndEnded
  • 即使同时触发了 Failure 事件,也必须触发 Stop 事件——把 Stop 当作 finally 块对待,保证配对事件完整。
  • 错误事件后缀用 Failure
  • 动词用现在时(Timeout 而不是 TimedOut)。
  • 采用 NounVerb 语序ConnectionStart 而不是 StartConnection)。

代码组织

  • 在 EventSource 类型内,所有带 [Event] 的方法放在一起,并按 eventId 排序。

计时事件的标准套路

对需要计时的操作,文档给出了非常具体的启停协作约定:

  • Start 事件方法返回 ValueStopwatch
  • 当事件被禁用时返回 default(ValueStopwatch),启用时才返回 ValueStopwatch.StartNew()
  • End 事件接收该 ValueStopwatch,先通过 .IsActive 判断它是否真的被启动了,若是再用 GetElapsedTime 计算耗时。

这套逻辑保证了事件被禁用时不会付出调用 ValueStopwatch.StartNew() 的性能开销。仓库里的 ValueStopwatch 实现 印证了这一设计:IsActive => _startTimestamp != 0,当未初始化(default)时 GetElapsedTime() 会抛出 InvalidOperationException;在 .NET 7+ 上它直接委托给 Stopwatch.GetElapsedTime,早期 TFM 则通过 TimestampToTicks 手工换算,StartNew()Stopwatch.GetTimestamp() 只取一个时间戳,全程零分配。

代码风格(Code Style)

为了让 EventSource 在诊断工具中可被稳定识别,仓库对 EventSource 类的形态有一致性要求:

规则 要求 依据/示例
事件源名称 与所在程序集名一致,但 . 换成 - Microsoft.AspNetCore.AuthenticationMicrosoft-AspNetCore-Authentication
可见性 一律 internal KestrelEventSource.csinternal sealed class
构造函数 声明 private 无参构造函数 private KestrelEventSource() { }
单例 声明 public static readonly 实例,命名为 Log public static readonly KestrelEventSource Log = new KestrelEventSource();
类型名后缀 类型名以 EventSource 结尾 DependencyInjectionEventSource

EventSource 名称之所以必须是 internal,是因为它们是进程内全局概念,对外暴露引用只会诱导使用者把库内部的可观测性细节当成公共 API。

事件实现模式(Event Pattern)

完整形态:带计数器 + 复杂 payload

下面是文档给出的"覆盖几乎所有场景"的标准形态。核心思想是两层方法分工[NonEvent] 包装方法负责类型安全地接收富对象、做复杂计算与分级开关判断;[Event] 私有方法只负责向 ETW 写出可序列化的基元 payload。

// 必须标注 NonEventAttribute,否则 EventSource 会尝试为它自动生成 manifest!
[NonEvent]
public void SomethingHappened(ObjectNeededToCalculateThePayload p, AnotherObjectNeededToCalculateThePayload p2)
{
  // 检查 source 是否启用(不区分 level)
  if (IsEnabled())
  {
    // 若该事件关联了计数器,无论 level 如何都写入指标
    _somethingsHappenedCounter.WriteMetric(1.0f);

    // 再检查这个具体事件是否按 level(可选 keywords)启用
    if (IsEnabled(EventLevel.Informational, EventKeywords.None))
    {
      // 做任何计算 payload 所需的复杂操作
      var payloadValue = CalculateThePayload(p);

      // 触发真正的事件方法
      SomethingHappened(payloadValue, p2.MorePayload, p2.SomeValue - p2.SomeOtherValue);
    }
  }
}

// 必须是独立方法,EventSource 才能为它生成 ETW manifest。
// eventId 字段必填,且必须与传给 WriteEvent 的 id 一致。
[Event(eventId: 42, Level = EventLevel.Informational)]
private void SomethingHappened(string payloadValue, int anotherPayloadValue, double morePayload) => WriteEvent(42, payloadValue, anotherPayloadValue, morePayload);

要点解读:

  • 计数器写入放在外层 IsEnabled() 检查之后、具体事件 level 检查之前,因为文档约定:只要 EventSource 被启用就写计数器,而不受 level/keyword 控制(详见下文"事件计数器"一节)。
  • WriteEvent(42, ...) 第一个参数是 eventId,必须与 [Event(eventId: 42)] 严格一致——这正是后面 EventSourceValidator 测试要守护的约束。
  • 方法体写成 expression-bodied => 只是风格选择,重点是 eventId 同步。

简化形态:简单 payload、无计数器

当没有复杂 payload 计算、也不关联计数器时,可以把全部逻辑收敛到一个带 [Event] 的方法里,直接用 IsEnabled(EventLevel, EventKeyword) 重载判断:

[Event(eventId: 42, Level = EventLevel.Informational)]
public void SomethingHappened(string payloadValue)
{
  if (IsEnabled(EventLevel.Informational, EventKeywords.None))
  {
    WriteEvent(42, payloadValue);
  }
}

关键字(Keywords)

当某些事件只希望用户显式要求时才启用,可以用 Keywords 控制。Keywords 是一个简单的标志位枚举值,在监听器启用某个 EventSource 时提供,通过 IsEnabled 检查生效——属于按需细分的能力开关,适用于"默认不开、诊断时打开"的详细事件。

事件计数器(Event Counters)

文档指出,EventCounter 只有在监听器为 EventSource 提供 EventCounterIntervalSec 参数时才会真正启用,因此不需要用 level 或 keyword 去控制它们。仓库的约定是:EventSource 本身一旦被启用,就始终向计数器写入(对应上面完整形态里外层 IsEnabled() + 无条件 WriteMetric 的结构)。

计数器会提供多种聚合(Count、Mean、StdDev、Min、Max),而不同"种类"的计数器适合不同的聚合维度。仓库把计数器分为三类:

种类 语义 写入方式 消费者如何解读 命名要求 例子
Counter(计数) 某事件发生的次数 .WriteMetric(1.0f) 读取某时间区间内的 "Count" 聚合,得到事件发生次数 复数名词 + 形容词 RequestsStarted
Metric(指标) 随时间或按"单位"变化的值(如每次请求、每条连接) .WriteMetric(当前值) 用各聚合值了解指标随时间的分布 描述该指标的单数名词 RequestBodySize
Duration(时长) 一种以毫秒记录时长的 Metric .WriteMetric(毫秒数) 同上 名字以 Duration 结尾 RequestDuration

值得注意:Kestrel 的生产实现 KestrelEventSource.cs 在此之上还使用了 PollingCounterIncrementingPollingCounter(配合 Interlocked 维护的连接数/队列长度等字段),用于采样"当前值"类指标——这说明实际库往往组合 EventCounter 与 PollingCounter 来覆盖计数、时长、瞬时水位三类可观测性需求。

完整示例:一个认证中间件的 EventSource

以下是文档给出的认证场景 EventSource 完整示例(straw-man),把上述所有规则落成可编译代码。注意类名、事件源名、Log 单例、私有构造、[NonEvent][Event] 分层、Start/Stop 共享 traceIdentifierpath、Stop 携带 durationMilliseconds、Failure 展开异常三元组等要点全部齐备:

using System;
using System.Diagnostics.Tracing;
using Microsoft.AspNetCore.Http;

namespace Microsoft.AspNetCore.Authentication.Internal
{
    [EventSource(Name = "Microsoft-AspNetCore-Authentication")]
    public class AuthenticationEventSource : EventSource
    {
        public static readonly AuthenticationEventSource Log = new AuthenticationEventSource();
        private readonly EventCounter _authenticationMiddlewareDuration;

        private AuthenticationEventSource()
        {
            _authenticationMiddlewareDuration = new EventCounter("AuthenticationMiddlewareDuration", this);
        }

        [NonEvent]
        internal void AuthenticationMiddlewareStart(HttpContext context)
        {
            if (IsEnabled(EventLevel.Informational, EventKeywords.None))
            {
                AuthenticationMiddlewareStart(context.TraceIdentifier, context.Request.Path.Value);
            }
        }

        [NonEvent]
        internal void AuthenticationMiddlewareEnd(HttpContext context, TimeSpan duration)
        {
            if (IsEnabled())
            {
                _authenticationMiddlewareDuration.WriteMetric((float)duration.TotalMilliseconds);

                if (IsEnabled(EventLevel.Informational, EventKeywords.None))
                {
                    AuthenticationMiddlewareEnd(context.TraceIdentifier, context.Request.Path.Value, duration.TotalMilliseconds);
                }
            }
        }

        [NonEvent]
        internal void AuthenticationMiddlewareFailure(HttpContext context, Exception ex)
        {
            if(IsEnabled(EventLevel.Error, EventKeywords.None))
            {
                AuthenticationMiddlewareFailure(context.TraceIdentifier, context.Request.Path.Value, ex.GetType().FullName, ex.Message, ex.ToString());
            }
        }

        [Event(eventId: 1, Level = EventLevel.Informational)]
        private void AuthenticationMiddlewareStart(string traceIdentifier, string path) => WriteEvent(1, traceIdentifier, path);

        [Event(eventId: 2, Level = EventLevel.Informational)]
        private void AuthenticationMiddlewareEnd(string traceIdentifier, string path, double durationMilliseconds) => WriteEvent(2, traceIdentifier, path, durationMilliseconds);

        [Event(eventId: 3, Level = EventLevel.Error)]
        private void AuthenticationMiddlewareFailure(string traceIdentifier, string value, string exceptionTypeName, string message, string fullException) => WriteEvent(3, traceIdentifier, value, exceptionTypeName, message, fullException);
    }
}

仓库中的真实对照实现:KestrelEventSource

上述示例并非纸上谈兵。以 KestrelEventSource.cs 为例,它是 ASP.NET Core 仓库中遵循该套模式的真实生产实现:

  • 类声明处(第 16-19 行):[EventSource(Name = "Microsoft-AspNetCore-Server-Kestrel")] internal sealed class ... : EventSource,并带 public static readonly ... Log 单例与私有构造。
  • 源码注释特别强调:Start/Stop 后缀在 EventSource 中具有特殊含义,会激活 activity 关联(correlation)能力,且 Stop 事件的 eventId 必须是其 Start 的下一个值;同时避免重命名带 [Event] 的方法或参数,因为 EventSource 靠它们构成事件对象。
  • ConnectionStart第 55-76 行):先无条件用 Interlocked.Increment 维护计数器的底层字段,再在 IsEnabled(EventLevel.Informational, EventKeywords.None) 内展开连接三要素,体现了"低开销 + 延迟分配"的双层写法;ConnectionStop第 78-94 行)递减 _currentConnections 后触发 event 2,与 event 1 通过 connectionId 关联。
  • RequestStart第 96-119 行)使用 [NonEvent] 局部函数做二次判断,避免在日志未启用时分配 trace identifier 字符串——与文档"避免在禁用时付出不必要开销"的原则一脉相承。

EventSource 的自动化测试

文档强调一个残酷现实:EventSource 的许多错误(如 [Event] 的 eventId 与 WriteEvent 实参不一致)只有到运行时才会暴露。为此仓库在 src/Testing/src/Tracing/ 下提供了一套专门的测试设施,包含两个互补的测试维度。

维度一:校验 Event ID 一致性(EventSourceValidator)

所有 EventSource 子类都应有一个测试,用于校验 [Event(N)] 特性中的 ID 与 WriteEvent(N, ...) 调用实参是否匹配。这能捕获因错误合并或漏更新导致的漂移——这类问题平时无感,只会在运行时以错误形式爆出。

工具是 Microsoft.AspNetCore.InternalTesting.Tracing 命名空间下的 EventSourceValidator

using Microsoft.AspNetCore.InternalTesting.Tracing;

public class MyEventSourceTests
{
    [Fact]
    public void EventIdsAreConsistent()
    {
        EventSourceValidator.ValidateEventSourceIds<MyEventSource>();
    }
}

EventSourceValidator.cs 的实现看,它做了两件事:

  1. 重复 ID 检查:遍历类型上所有 [Event] 标注方法(含非 public、仅 DeclaredOnly),用字典登记每个 EventId,发现重复即报错。
  2. IL 级校验:调用 EventSource.GenerateManifest(type, "assemblyPathToIncludeInManifest", EventManifestOptions.Strict),让运行时内部用 GetHelperCallFirstArg 反汇编检查每个方法体中传给 WriteEvent 的整数常量是否与 [Event(id)] 一致——这正是 .NET 运行时构造 EventSource 时执行的同一套校验。任何不匹配都会以 ArgumentException 形式抛出并被收集为测试失败。

仓库中已有真实用例,例如 Kestrel 的测试 KestrelEventSourceTests.cs 通过反射拿到内部类型后调用 EventSourceValidator.ValidateEventSourceIds(esType),同文件还展示了用 EventSource.GetName/GetGuid/GenerateManifest 校验事件源名称、GUID 与 manifest 有效性的配套做法。

重要约定:每一个新增的 EventSource 类都应包含这一行校验测试。

维度二:功能测试(EventSourceTestBase)

除了静态校验,还可以对事件源做"真实触发"的功能测试。基类位于 src/Testing/src/Tracing/EventSourceTestBase.cs

// 测试 EventSource 必须使用该基类:EventSource 是进程全局的,并行测试会引发问题。
// 基类加入了相应机制来规避。
public class SomeTest : EventSourceTestBase
{
    [Fact]
    public void TestName()
    {
        // Arrange: 显式注册要监听的事件源
        CollectFrom("Microsoft-AspNetCore-SomeEventSourceName");

        // Act: 做一些会触发事件的操作
        DoStuff();

        // Assert: 取出收集到的事件并断言符合预期
        var events = GetEvents();

        // EventAssert 是测试事件的辅助类。它的特殊之处在于:
        // EventAssert.Event 返回一个"builder",用于构造 Action<EventWrittenEventArgs>,
        // 在被调用时会断言你配置的各项内容。这种模式让测试代码更清晰。
        EventAssert.Collection(events,
            EventAssert.Event(1, "Test", EventLevel.Informational),
            EventAssert.Event(2, "TestWithPayload", EventLevel.Verbose)
                .Payload("payload1", 42)
                .Payload("payload2", 4.2));
    }
}

这套设施为何能"处理全局并行问题"?从源码可以看清三块机制:

  1. 串行化集合EventSourceTestBase.cs 类上标注了 [Collection(CollectionName)](常量值为 "Microsoft.AspNetCore.InternalTesting.Tracing.EventSourceTestCollection"),这个 xUnit collection 特性会强制所有继承它的测试顺序执行,从根上避免进程级 EventSource/EventListener 相互踩踏。
  2. 收集监听器:底层是 CollectingEventListener.cs —— 一个 EventListener 子类,用 ConcurrentQueue<EventWrittenEventArgs> 缓存事件。CollectFrom(string) 支持"按名预约":若目标 EventSource 尚未创建就记入待启用集合,待 OnEventSourceCreated 回调到来时立即补启用,规避了监听器与源创建的先后竞争;实际启用时调用 EnableEvents(source, EventLevel.Verbose, EventKeywords.All),即以 Verbose 级别全量接收。
  3. 链式断言EventAssert.csEvent(id, name, level) 返回 builder,.Payload(name, expectedValue)(或 .Payload(name, Action<object>) 自定义断言)逐步追加校验;EventAssert.Collection 把每个 builder 转成 Action<EventWrittenEventArgs> 后交给 xUnit 的 Assert.Collection,同时校验事件的 EventIdEventNameLevel,以及 PayloadNamesPayload 的逐项对应。

这样 Arrange → Act → Assert 三段式中,前两段与普通测试几乎无异,区别只在通过基类提供的 CollectFrom / GetEvents 完成"捕获",从而在不依赖外部 ETW 会话的情况下验证"某个操作真的发出了期望的事件序列"。

已知限制(文档明确标注):当前测试监听器暂不支持收集 EventCounters。如果你的测试需要验证计数器数据,需要在仓库测试基础设施中另行提交 issue 跟进,目前可验证的范围是事件本身(含 payload),而非计数器的区间聚合输出。

小结

把本指南浓缩成三条落地清单:

  1. :任何新 EventSource 按统一代码风格落盘——internal + 私有构造 + static readonly LogAssembly.Name- 作为源名、类型以 EventSource 结尾;事件命名遵循 NounVerbStart/Stop/Failure 后缀与现在时约定。
  2. :Start 事件方法返回 ValueStopwatch,Stop 事件固定带 double durationInMilliseconds 且与 Start 共享关联键;复杂 payload 与异常(展开为 exceptionType/exceptionMessage/exceptionDetails)通过 [NonEvent] 包装层处理,[Event] 方法只做基元写出。
  3. :为每个 EventSource 添加一行 EventSourceValidator.ValidateEventSourceIds<T>() 防止 ID 漂移;凡是需要验证"触发行为"的测试,继承 EventSourceTestBase(获得 xUnit 串行化 + CollectFrom/GetEvents/EventAssert 能力)。

遵循这套源自 ASP.NET Core 仓库自身的规范,你的类库事件在 PerfView、dotnet-trace 等工具中会呈现一致、可关联、可聚合的结构,同时获得自动化测试对"运行时才暴露"类问题的兜底。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.14 K
2.74 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
857
1.35 K
docsdocs
暂无描述
Markdown
897
5.81 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
531
595
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
920
1.84 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.63 K
1.02 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.36 K
1.46 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.02 K
518
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
547
389