c# ActivitySource 和 OpenTelemetry 在异步代码中的上下文传递

1次阅读

activity 在 async/await 中丢失,因 startactivity() 返回未启动 activity,须显式调用 .start() 并用 using 确保生命周期覆盖整个异步作用域,否则 activity.current 为 NULL

c# ActivitySource 和 OpenTelemetry 在异步代码中的上下文传递

ActivitySource 创建的 Activity 在 async/await 中为什么会丢失?

因为 Activity 依赖 AsyncLocal<activity></activity> 实现上下文传递,而 .NET 的异步执行流(如 TaskValueTask)默认会捕获并还原 AsyncLocal 值——但前提是 Activity 必须在进入异步边界前被显式启动并设置为当前 Activity。如果只调用 StartActivity() 但没调用 SetParentId() 或没正确处理父级上下文,后续 await 后的代码里 Activity.Current 就是 null

  • 常见错误:在 async Task 方法里直接 var activity = source.StartActivity("work"),然后立刻 await,之后再想用 Activity.Current 记录日志或指标 → 此时已为 null
  • 根本原因:OpenTelemetry SDK 默认不自动将新 Activity 设为当前上下文;必须显式调用 activity?.Start(); activity?.SetParentId(...),或更稳妥地用 using var activity = source.StartActivity(...) + 确保其生命周期覆盖整个异步作用域
  • 注意 ActivitySource.StartActivity() 返回的是未启动的 Activity,需手动 .Start() 才真正进入上下文

如何让 OpenTelemetry 正确注入和提取 W3C TraceContext?

异步调用跨服务(如 http 调用)时,Trace ID 和 Span ID 必须通过 HTTP Header(traceparent / tracestate)传播。OpenTelemetry .NET SDK 默认启用 W3C 格式,但前提是 ActivitySource 创建的 Activity 已正确启动且设为当前上下文,否则 HttpClientHttpMessageHandler 集成无法读取 Activity.Current 并注入 Header。

  • 确保 Activity 在发起 HTTP 请求前已启动并设为当前:例如 using var activity = source.StartActivity("http-out"); activity?.Start();
  • 检查是否注册了 AddHttpClientInstrumentation():它依赖 DiagnosticSource 监听 System.Net.Http 事件,若未启用则不会自动注入/提取 trace context
  • 手动注入场景(如自定义 HTTP 客户端):用 OpenTelemetry.Context.Propagation.HttpTraceContext.Inject(...),传入 Activity.Current?.Context,否则注入空 context

为什么 await 之后 Activity.Current 是 null,但 SpanBuilder 仍能生成子 Span?

因为 OpenTelemetry 的 Tracer(如 Sdk.CreateTracerProviderBuilder() 构建的)在创建 ISpan 时,会尝试从 Activity.Current 获取父级上下文;但如果 Activity.Current == null,它会 fallback 到“无父级”的 root span —— 这看起来像“还能工作”,实则是丢失了调用链,所有 Span 都变成孤立根节点。

  • 典型现象:Jaeger 或 Zipkin 中看到一同名、同时间戳、无父子关系的 http-out Span
  • 验证方式:在 await 后加 console.WriteLine(Activity.Current?.Id),输出 null 即确认上下文断裂
  • 修复关键:不要依赖“Span 自动找父级”,而要确保 Activity 生命周期贯穿整个 async 方法体,推荐用 using 块包裹 StartActivity() 调用,并在 await 前完成 .Start()
using var activity = MyActivitySource.StartActivity("process-item"); activity?.Start(); // 必须调用!否则 Activity.Current 不生效  await DoWorkAsync(); // 此处 Activity.Current 仍有效  // 后续操作可安全访问 activity?.AddTag("processed", true); activity?.SetStatus(Status.Ok);

AsyncLocal 和 Activity.Current 的行为差异容易被忽略

Activity.CurrentAsyncLocal<activity></activity>封装属性,但它只在 Activity.Start() 后才写入 AsyncLocal。而 ActivitySource.StartActivity() 返回的对象默认是 IsAllDataRequested == false 且未启动,此时即使赋值给局部变量,也不会影响 Activity.Current

  • 错误写法:var activity = source.StartActivity("x"); await Task.Delay(1); activity?.Start();await 期间 Activity.Current 为空
  • 正确顺序:先 .Start(),再 await,且 activity 对象生命周期必须跨越 await
  • 特别注意 ValueTask:它可能同步完成,也可能异步,但 AsyncLocal 行为一致;不能假设“同步完成就不用管上下文”

跨 async 方法传递 Activity 最可靠的方式不是靠 Activity.Current 自动延续,而是显式传参 + 在每个 async 方法入口重新绑定:把 Activity.Context 作为参数传入,用 Activity.SetParentId() 恢复上下文。这听起来繁琐,但在复杂调度(如 Task.Run线程池回调、Timer 回调)中是唯一可控手段。

text=ZqhQzanResources