For the complete documentation index, see llms.txt. Markdown versions are available by appending .md to documentation URLs.

How to Manually Instrument .NET apps

SigNoz Cloud - This page applies to SigNoz Cloud editions.
Self-Host - This page applies to self-hosted SigNoz editions.

Manual instrumentation gives you programmatic control over OpenTelemetry in your .NET application. Use this approach when you need to create custom spans, add business-specific attributes, or when zero-code automatic instrumentation doesn't cover your use case.

Prerequisites

Step 1. Install dependencies

Add the required packages to your project:

dotnet add package OpenTelemetry
dotnet add package OpenTelemetry.Exporter.OpenTelemetryProtocol
dotnet add package OpenTelemetry.Extensions.Hosting
dotnet add package OpenTelemetry.Instrumentation.AspNetCore

For manual instrumentation, the core dependency is System.Diagnostics.DiagnosticSource (included with .NET 5+).

Step 2. Create an ActivitySource

Create a dedicated class to hold your ActivitySource. This is your entry point for creating spans:

Instrumentation.cs
using System.Diagnostics;
 
public class Instrumentation : IDisposable
{
    public const string ActivitySourceName = "MyApp.OrderService";
    public const string ActivitySourceVersion = "1.0.0";
 
    public ActivitySource ActivitySource { get; }
 
    public Instrumentation()
    {
        ActivitySource = new ActivitySource(ActivitySourceName, ActivitySourceVersion);
    }
 
    public void Dispose()
    {
        ActivitySource.Dispose();
    }
}

Register it in your DI container:

Program.cs
builder.Services.AddSingleton<Instrumentation>();

Step 3. Configure OpenTelemetry

Set up the TracerProvider and register your ActivitySource:

Program.cs
using OpenTelemetry.Exporter;
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;
 
var builder = WebApplication.CreateBuilder(args);
 
builder.Services.AddSingleton<Instrumentation>();
 
builder.Services.AddOpenTelemetry()
    .ConfigureResource(resource => resource
        .AddService("my-dotnet-service", serviceVersion: "<service-version>"))
    .WithTracing(tracing => tracing
        .AddSource(Instrumentation.ActivitySourceName) // Register your ActivitySource
        .AddAspNetCoreInstrumentation()
        .AddHttpClientInstrumentation()
        .AddOtlpExporter(options =>
        {
            options.Endpoint = new Uri("https://ingest.<region>.signoz.cloud:443/v1/traces");
            options.Headers = "signoz-ingestion-key=<your-ingestion-key>";
            options.Protocol = OtlpExportProtocol.HttpProtobuf;
        }));

Verify these values:

  • <region>: Your SigNoz Cloud region
  • <your-ingestion-key>: Your SigNoz ingestion key
  • <service-version> (optional): Your release version, image tag, or git SHA (e.g., 1.4.2, a01dbef8).

Step 4. Create custom spans

Use StartActivity() to create spans. The using pattern ensures spans are properly ended:

Services/OrderService.cs
using System.Diagnostics;
 
public class OrderService
{
    private readonly Instrumentation _instrumentation;
 
    public OrderService(Instrumentation instrumentation)
    {
        _instrumentation = instrumentation;
    }
 
    public async Task<Order> ProcessOrderAsync(string orderId)
    {
        // Start a new span
        using var activity = _instrumentation.ActivitySource.StartActivity("ProcessOrder");
 
        // Add attributes
        activity?.SetTag("order.id", orderId);
        activity?.SetTag("order.status", "processing");
 
        // Your business logic here
        var order = await FetchOrder(orderId);
 
        activity?.SetTag("order.total", order.Total);
 
        return order;
    }
}

Step 5. Create nested spans

Child spans automatically link to their parent when you start them within a parent's scope:

Services/DiceService.cs
public int RollDice()
{
    using var parentActivity = _instrumentation.ActivitySource.StartActivity("RollDice");
 
    int total = 0;
    for (int i = 0; i < 3; i++)
    {
        total += RollOnce();
    }
 
    parentActivity?.SetTag("dice.total", total);
    return total;
}
 
private int RollOnce()
{
    // This creates a child span under the parent
    using var childActivity = _instrumentation.ActivitySource.StartActivity("RollOnce");
 
    int result = Random.Shared.Next(1, 7);
    childActivity?.SetTag("dice.result", result);
 
    return result;
}

Step 6. Propagate context

Activity.Current is backed by an AsyncLocal<Activity>, so the active span follows your code across await and Task.Run without any work from you. Two things break that chain: a thread that started before the activity existed, and another service. See Context Propagation for the concepts behind this step.

Inside your process

To parent a span explicitly rather than relying on Activity.Current, pass an ActivityContext to StartActivity:

Services/BackgroundWorker.cs
// Capture the context while the parent activity is still current
var parentContext = Activity.Current?.Context ?? default;
 
_backgroundQueue.Enqueue(() =>
{
    // The worker thread has no ambient activity, so pass the context in
    using var activity = _instrumentation.ActivitySource.StartActivity(
        "settle-order",
        ActivityKind.Internal,
        parentContext);
 
    SettleOrder(orderId);
});

Across a service boundary

AddAspNetCoreInstrumentation() and AddHttpClientInstrumentation() inject and extract for you. Write the calls yourself for any hop they do not cover, such as a message queue.

On the publishing side, inject the current context into the message headers:

Publisher.cs
using OpenTelemetry;
using OpenTelemetry.Context.Propagation;
 
public void PublishOrder(Order order)
{
    using var activity = _instrumentation.ActivitySource.StartActivity("publish-order", ActivityKind.Producer);
 
    var message = new QueueMessage { Body = order, Headers = new Dictionary<string, string>() };
    var contextToInject = activity?.Context ?? Activity.Current?.Context ?? default;
 
    // Read the property here, not into a static field. The SDK replaces it
    // during initialization, so a field captured earlier keeps the no-op.
    Propagators.DefaultTextMapPropagator.Inject(
        new PropagationContext(contextToInject, Baggage.Current),
        message.Headers,
        (headers, key, value) => headers[key] = value);
 
    _queue.Send(message);
}

On the consuming side, extract before you start the entry span:

Consumer.cs
using System.Linq;
 
public void OnMessage(QueueMessage message)
{
    var parentContext = Propagators.DefaultTextMapPropagator.Extract(
        default,
        message.Headers,
        (headers, key) => headers.TryGetValue(key, out var value)
            ? new[] { value }
            : Enumerable.Empty<string>());
 
    Baggage.Current = parentContext.Baggage;
 
    using var activity = _instrumentation.ActivitySource.StartActivity(
        "process-order",
        ActivityKind.Consumer,
        parentContext.ActivityContext);
 
    // process-order is now a child of publish-order in the publisher's trace
    Handle(message.Body);
}

Step 7. Add events

Events mark specific moments within a span:

using var activity = _instrumentation.ActivitySource.StartActivity("ProcessPayment");
 
activity?.AddEvent(new ActivityEvent("PaymentInitiated"));
 
// Process payment...
 
activity?.AddEvent(new ActivityEvent("PaymentCompleted", DateTimeOffset.UtcNow,
    new ActivityTagsCollection
    {
        { "transaction.id", "txn_123456" },
        { "payment.method", "credit_card" }
    }));

Step 8. Record errors

Mark spans as failed and attach exception details:

using OpenTelemetry.Trace;
 
public async Task<Result> RiskyOperationAsync()
{
    using var activity = _instrumentation.ActivitySource.StartActivity("RiskyOperation");
 
    try
    {
        var result = await DoSomethingRisky();
        activity?.SetStatus(ActivityStatusCode.Ok);
        return result;
    }
    catch (Exception ex)
    {
        activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
        activity?.RecordException(ex);
        throw;
    }
}

The RecordException method is an extension method from OpenTelemetry.Trace that adds the exception as an event with stack trace details.

Step 9. Access the current span

Get the active span from anywhere in your code:

var currentActivity = Activity.Current;
currentActivity?.SetTag("additional.context", "some-value");
currentActivity?.AddEvent(new ActivityEvent("checkpoint-reached"));

This is useful for adding context from code that doesn't have direct access to the activity.

Validate

  1. Generate traffic by making requests to your application.
  2. Open SigNoz and go to Services.
  3. Look for your service name in the list.
  4. Click on your service to view traces, latency, and error rates.

Troubleshooting

Still not seeing data in SigNoz? Work through Debug missing traces, logs, and metrics, which covers SDK diagnostics, Collector connectivity, and the common ingestion errors for all three signals.

Why don't my custom spans appear?

  • Verify your ActivitySource name matches what's registered with AddSource() in Program.cs
  • Ensure the TracerProvider is configured before your application starts handling requests
  • Check that traffic actually reaches the instrumented code paths

Why does StartActivity() return null?

  • No listener is registered for your ActivitySource (check AddSource() configuration)
  • The activity was sampled out by your sampler configuration
  • The TracerProvider wasn't set up correctly

How do I debug spans locally?

Add the console exporter:

dotnet add package OpenTelemetry.Exporter.Console
.WithTracing(tracing => tracing
    .AddSource(Instrumentation.ActivitySourceName)
    .AddConsoleExporter() // Prints spans to console
    .AddOtlpExporter(/* ... */));

Why does the downstream service start its own trace?

  • Make sure that the publisher runs Inject and that the message carries a traceparent entry.
  • Make sure that the consumer passes parentContext.ActivityContext to StartActivity.
  • Make sure that the tracer provider is built before the first Inject call. Propagators.DefaultTextMapPropagator stays a no-op until the SDK sets it.
  • Make sure that both services register the same propagator format. See Context Propagation.

Why are child spans not linked to parents?

  • Ensure you create child activities within the using block of the parent
  • Don't use Task.Run() or thread pool operations without propagating context
  • For async operations, the context flows automatically if you use async/await

Next steps

  • Setup alerts for your traces to get notified on errors and latency
  • Read Context Propagation for the wire format and the failure modes shared across languages

Is this page helpful

Last updated—September 03, 2026

Edit on GitHub