Propagating ActivityId with AsyncLocal in C#

Tsuyoshi Ushio
3 min readSep 13, 2024

--

Distributed Tracing and Activity Id

In the C# ecosystem, the term ActivityId appears in several contexts:

These are all under the System.Diagnostics namespace. However, when working with async and await, each handles ActivityId differently, which can lead to confusion.

What I Wanted to Achieve

The goal of my research was to find the best way to:

  • Set an ActivityId (a Guid) from a request or background thread and propagate it using AsyncLocal.
  • Use EventSource to emit ETW (Event Tracing for Windows) events that include the ActivityId.

The Issue

EventSource provides a method called CurrentThreadActivityId that sets the ActivityId for ETW events. However, as the name suggests, it is thread-local, not async-local. This means:

  • If you use async/await and the execution moves to a different thread, the ActivityId does not carry over.
  • If you set an ActivityId and the thread changes, the ActivityId might appear in a different call context, which is undesirable.

While EventSource supports async-local correlation propagation by starting a scope that generates a new ActivityId with hierarchical relationships, this wasn't suitable for my needs. I wanted to use the same ActivityId throughout the async-local code execution.

Other classes like Activity (System.Diagnostics.Activity) support async-local propagation when using Start()/Stop() methods, but they don't integrate with EventSource. Similarly, Trace.CorrelationManager.ActivityId is async-local but doesn't relate to EventSource's ActivityId.

For more information:

Activity Class (System.Diagnostics) | Microsoft Learn

CorrelationManager Class (System.Diagnostics) | Microsoft Learn

EventSource Activity IDs — .NET | Microsoft Learn

The Solution

An expert introduced me to an effective solution. Let me explain:

using System.Diagnostics.Tracing;

class Program
{
static void Main(string[] args)
{
HandleRequest(Guid.NewGuid());
}

static void HandleRequest(Guid activityId)
{
Console.WriteLine("Starting request with ID: " + activityId);

MyActivityTracker.SetActivityId(activityId);
DoSomeWork();
Task.Run(() => DoSomeWork());
MyActivityTracker.SetActivityId(Guid.Empty);
}

static void DoSomeWork()
{
Console.WriteLine($" ThreadId: {Thread.CurrentThread.ManagedThreadId} ActivityId: {EventSource.CurrentThreadActivityId}");
}
}

static class MyActivityTracker
{
static AsyncLocal<Guid> _activityId = new AsyncLocal<Guid>(ActivityIdChanged);

public static void SetActivityId(Guid activityId)
{
_activityId.Value = activityId;
}

private static void ActivityIdChanged(AsyncLocalValueChangedArgs<Guid> args)
{
EventSource.SetCurrentThreadActivityId(args.CurrentValue);
}
}

Explanation

This solution leverages the constructor of AsyncLocal<T> that accepts a valueChangedHandler delegate:

public AsyncLocal (Action<System.Threading.AsyncLocalValueChangedArgs<T>>? valueChangedHandler);
  • Value Change Handler: The ActivityIdChanged delegate is called whenever the current value changes on any thread.
  • ExecutionContext: Changes include those due to the ExecutionContext, so if you use async/await and execution moves to a different thread, the delegate is invoked.
  • Thread Assignment: For each thread assignment, the delegate sets the ActivityId to the thread from the AsyncLocal value.

This approach ensures that the ActivityId is consistently propagated across asynchronous calls and thread switches.

For more details:
AsyncLocal<T> Constructor (System.Threading) | Microsoft Learn

Conclusion

With the expert’s guidance, I successfully resolved the issue by using AsyncLocal to propagate a consistent ActivityId across asynchronous operations. This approach ensures that ETW events emitted via EventSource include the correct ActivityId, even when the execution context changes due to async/await.

However, for a more sustainable long-term solution, it might be advisable to transition from GUID-based ActivityIds to using the W3C Trace Context or OpenTelemetry standards for distributed tracing propagation. These standards offer a more robust and interoperable framework for tracing across distributed systems, enhancing observability and simplifying integration with modern monitoring tools.

--

--