Propagating ActivityId with AsyncLocal in C#
In the C# ecosystem, the term ActivityId appears in several contexts:
- System.Diagnostics.Activity
- System.Diagnostics.CorrelationManager
- System.Diagnostics.EventSource
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(aGuid) from a request or background thread and propagate it usingAsyncLocal. - Use
EventSourceto emit ETW (Event Tracing for Windows) events that include theActivityId.
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/awaitand the execution moves to a different thread, theActivityIddoes not carry over. - If you set an
ActivityIdand the thread changes, theActivityIdmight 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
ActivityIdChangeddelegate is called whenever the current value changes on any thread. - ExecutionContext: Changes include those due to the
ExecutionContext, so if you useasync/awaitand execution moves to a different thread, the delegate is invoked. - Thread Assignment: For each thread assignment, the delegate sets the
ActivityIdto the thread from theAsyncLocalvalue.
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.