Adding Custom Activities & Context to Your .NET Traces

08.06

In our previous blog post, we highlighted the importance of combining automatic and manual instrumentation. While auto tracing quickly provides broad coverage, you only get deep insights by adding your own custom activities (often called spans in OpenTelemetry terms).  

This post explains how .NET developers can practically tackle this with OpenTelemetry and the System.Diagnostics.Activity system, which integrates seamlessly with Application Insights. We’ll show how to create custom activities, enrich them with meaningful tags and baggage (context that propagates with them), and make sure they show up correctly in your observability backend, such as Azure Monitor Application Insights.

Step 1: Define your ActivitySource 

The basis for creating custom activities is the System.Diagnostics.ActivitySource. Think of it as a factory or source for your activities. It is crucial to give it a unique name, which you will use later in the OpenTelemetry configuration.  

It is common practice to define this as a static variable within a relevant class or a dedicated telemetry helper class, as shown here in the CatalogAPI:

private static readonly ActivitySource ActivitySource = new(nameof(CatalogApi));

Why is the name important? OpenTelemetry uses this source name to identify and filter activities. You will have to explicitly add this source name to your configuration (see Step 5).

Step 2: Create custom activities around your logic

Now you can use your ActivitySource to start and stop activities around specific code blocks. The StartActivity method creates a new activity (span). Here, a using statement is the recommended pattern. Here you can see how we apply this in the GetItems method: 

using (var getAllItemsActivity = ActivitySource.StartActivity("GetAllItems", ActivityKind.Server))
{
    getAllItemsActivity?.SetTag("status", "success");
    var pageSize = paginationRequest.PageSize;
    var pageIndex = paginationRequest.PageIndex;    var totalItems = await services.Context.CatalogItems
        .LongCountAsync();    var itemsOnPage = await services.Context.CatalogItems
        .OrderBy(c => c.Name)
        .Skip(pageSize * pageIndex)
        .Take(pageSize)
        .ToListAsync();if (totalItems <= 0 || itemsOnPage.Count <= 0)
    getAllItemsActivity?.SetTag("status", "warning");    return TypedResults.Ok(new PaginatedItems<CatalogItem>(pageIndex, pageSize, totalItems, itemsOnPage));
}

The result of adding this custom activity is clearly visible in the end-to-end transaction view in Azure Monitor, where our GetAllItems span is nested within the span generated by the framework:

A screenshot of the Microsoft Azure Monitor web interface.

A screenshot of the Microsoft Azure Monitor web interface.

Step 3: Add propagating context with baggage 

Sometimes, you have contextual information (such as a CustomerID or SessionID) that you want available not only on the current span, but also on all underlying spans created later in the process. This can even cross service boundaries if the context propagation is set up correctly. That’s where baggage comes in handy.

if (totalItems <= 0 || itemsOnPage.Count <= 0)
    getAllItemsActivity?.SetBaggage("status", "warning");

Although baggage is propagated, OpenTelemetry backends and UIs will often not automatically display baggage items as tags on spans. You usually need an extra step to make them visible. 

Step 4: Make baggage visible with a custom processor

To automatically add baggage items as tags to spans, we create a custom processor that inherits from BaseProcessor<Activity>: 

public class OpenTelemetryEnricher : BaseProcessor<Activity>
{
    private readonly IHttpContextAccessor _httpContextAccessor;

    public OpenTelemetryEnricher(IHttpContextAccessor httpContextAccessor)
    {
        _httpContextAccessor = httpContextAccessor;
    }

    public override void OnStart(Activity activity)
    {
        var customerId = _httpContextAccessor.HttpContext?.User.FindFirst("customerId")?.Value;
        if (!string.IsNullOrEmpty(customerId))
            activity.SetTag("customerId", customerId);
    }

    public override void OnEnd(Activity activity)
    {
        foreach (var baggage in activity.Baggage)
        {
            activity.SetTag(baggage.Key, baggage.Value);
        }
    }
}

Step 5: Configure OpenTelemetry to use your source and processor

Next, configure OpenTelemetry to use your ActivitySource (from step 1) and your custom processor (from step 4).

  • AddSource(): Explicitly tells OpenTelemetry to listen for activities created by your named ActivitySource. Without this, your custom activities are ignored. 
  • AddProcessor(): Registers your custom processor in the pipeline. It will now run for every activity processed by this tracer provider.
builder.Logging.AddOpenTelemetry(logging =>
{
logging.IncludeFormattedMessage = true;
logging.IncludeScopes = true;
});

builder.Services.AddOpenTelemetry()
.WithMetrics()
.WithTracing(tracing =>
{
    tracing.AddSource(nameof(CatalogApi));
    tracing.AddProcessor<OpenTelemetryEnricher>();
}

Conclusion 

Creating custom activities with relevant tags and propagating context via baggage elevates your observability significantly beyond basic automatic instrumentation. It allows you to align your traces directly with your application’s core logic and business concepts. 

At CloudFuel, we have deep expertise in designing and implementing advanced observability strategies like this one. Are you struggling with setting up custom tracing, optimising your OpenTelemetry configuration, or simply want to take the next step in getting more value from your observability data? Then contact CloudFuel. We’ll be happy to help you apply these techniques effectively in your specific environment. 

Smokescreen