A Lap Around ActivitySource and ActivityListener in .NET 5

Part of the new DiagnosticSource API are new ways of "listening" in to activities with the addition of the ActivitySource and ActivityListener APIs. These are intended to replace the DiagnosticSource and DiagnosticListener APIs. However, the latter two types aren't deprecated, and aren't being removed from the existing usages. However, ActivitySource/Listener represent a pretty big leap forward in usability and performance over the old APIs.

I've shown a little bit on the ActivitySource side of things, but there's the other side - listening to activity events. This is where the new API starts to show its benefits. The previous DiagnosticListener API used nested observables without much insight other than the events. You had one "callback" for when a new DiagnosticSource was available, then another for individual events:

using var sub = DiagnosticListener.AllListeners.Subscribe(listener =>
{
    Console.WriteLine($"Listener name {listener.Name}");

    listener.Subscribe(kvp => Console.WriteLine($"Received event {kvp.Key}:{kvp.Value}"));
});

The events didn't have any semantic meaning. The "key" for the event was whatever you wanted. The "value" was object, and could again be anything. Some events used semantic names for "Start" and "Stop" events, some did not. Some events had some context object passed in through the Value, some were anonymous types that required reflection (gross!) to get values out.

In the new world, the API is separated into the individual concerns when instrumenting:

  • Should I listen to these activities?
  • Should I sample this activity?
  • Notify me when an activity starts.
  • Notify when an activity stops.

With a DiagnosticListener, the DiagnosticListener.AllListeners property is an IObservable<DiagnosticListener>, from which you only have the Name to make a decision to listen to events.

The new API combines all of these into one single object:

public sealed class ActivityListener : IDisposable
{
    public Action<Activity>? ActivityStarted { get; set; }
    public Action<Activity>? ActivityStopped { get; set; }

    public Func<ActivitySource, bool>? ShouldListenTo { get; set; }

    public SampleActivity<string>? SampleUsingParentId { get; set; }

    public SampleActivity<ActivityContext>? Sample { get; set; }

    public void Dispose() => ActivitySource.DetachListener(this);
}

The SampleActivity type is a delegate:

public delegate ActivitySamplingResult SampleActivity<T>(ref ActivityCreationOptions<T> options);

This means we can have different sampling decisions based on how much detail we have. On Dispose, something nice, our ActivityListener automatically disconnects from the ActivitySource.

Attaching an ActivityListener is similar to DiagnosticListener, except now we simply call a method to add our listener:

ActivitySource.AddActivityListener(listener);

We now get explicit callbacks for Start/Stop, as well as for Sampling. Instrumentation libraries will use the Sample property to decide the level of sampling for this activity - some of which will eventually make it to trace context headers.

In my typical use case, I'm using sampling to do integration testing, so I'll typically have my listener set to ActivitySamplingResult.AllData. With the DiagnosticListener, there were no different levels of listening. Someone was either listening, or not, with no levels in between.

Next, we get distinct ActivityStarted and ActivityStopped callbacks, where we get the entire Activity instead of a KeyValuePair<string, object?>, much richer and focused API:

using var listener = new ActivityListener
{
    ShouldListenTo = _ => true,
    Sample = (ref ActivityCreationOptions<ActivityContext> _) => ActivitySamplingResult.AllData,
    ActivityStarted = activity => Console.WriteLine($"{activity.ParentId}:{activity.Id} - Start"),
    ActivityStopped = activity => Console.WriteLine($"{activity.ParentId}:{activity.Id} - Stop")
};

ActivitySource.AddActivityListener(listener);

One thing we don't have now is callbacks for generic events as we could do with the DiagnosticListener from before. It turns out all this information is now on our Activity:

  • Links
  • Tags
  • Events
  • Baggage

Rather than having a mystery meat object of the KeyValuePair from before, our Activity now has much more information (thanks to the OpenTelemetry folks for standardizing this information).

But what about existing users of DiagnosticListener? Well it turns out we can still get access to Activity start/stop events because Activity has a default Source that gets initialized with a blank Name. Although DiagnosticListener will not "forward" events to an ActivitySource, it's certainly possible.

While most folks will never really need to touch the Diagnostics API, the improvements with .NET 5 and following OpenTelemetry concepts mean the future is bright for telemetry and instrumentation - even if you only turn AppInsights on and forget about it.