Skip to main content
SEMastery
DevOpsbeginner

Getting Started With OpenTelemetry in .NET With Jaeger and Seq

A beginner guide to OpenTelemetry in .NET. Add traces, metrics, and logs, then view them in Jaeger and Seq using the OTLP exporter step by step.

13 min readUpdated November 27, 2025

A parcel with a tracking number

Think about ordering something online in India. Your parcel travels from the seller's warehouse, to a sorting hub, to a local courier, and finally to your door. You never see the trucks. But you can open the app and see a neat timeline: "Picked up", "Reached Delhi hub", "Out for delivery". Each stop has a time stamp. If the parcel is late, you can look at the timeline and see exactly which stop took too long.

Your software is just like that parcel journey. A single user request may travel through your API, then a database, then maybe another service, then back. When something is slow, you want a timeline that shows each stop and how long it took.

OpenTelemetry gives your code that tracking number. It records the journey of every request as a trace, made of small steps called spans. It also collects metrics (numbers, like how many requests per second) and logs (text messages). Then it ships all of this to a tool where you can look at it.

In this guide you will wire up OpenTelemetry in a .NET app and send the data to two popular tools: Jaeger, which draws beautiful trace timelines, and Seq, which is great for searching logs and traces together. We are using .NET 10 (the current LTS), but the same steps work on .NET 8 and 9.

The three signals

OpenTelemetry talks about three kinds of data. They are often called the three pillars of observability. Here they are in one picture.

Figure 1: The three signals OpenTelemetry collects from your app.

A quick way to remember what each one is for:

SignalWhat it answersExample
TraceWhere did the time go for one request?"Checkout took 800 ms, 700 ms was the database"
MetricHow is the whole system doing?"We serve 50 requests per second"
LogWhat exactly happened at this moment?"Order 42 failed: card declined"

You do not have to pick one. OpenTelemetry collects all three, and you send them out through one shared exporter.

How the pieces fit together

Before we write code, let us see the shape of the whole setup. Your app produces signals. The OpenTelemetry SDK gathers them. The OTLP exporter ships them over the network. A backend stores and shows them.

The OpenTelemetry pipeline

Instrument
Collect
Export
Ingest
View

Steps

1

Instrument

Your code emits spans, metrics, logs

2

Collect

The OTel SDK batches the data

3

Export

OTLP sends it over gRPC or HTTP

4

Ingest

Jaeger or Seq receives it

5

View

You read the timeline or search logs

Five stages from your code to a screen you can read.

The most important word here is OTLP. It stands for OpenTelemetry Protocol. It is the shared language for shipping telemetry. Because both Jaeger and Seq speak OTLP, the same .NET code can talk to either one. You only change the address you send to.

OTLP has two ways to travel:

TransportDefault portWhen to use
gRPC4317Default on .NET 8+, fast and compact
HTTP/protobuf4318Easy through firewalls and proxies

On .NET 8 and later the SDK defaults to gRPC. For Seq we will use the HTTP/protobuf path, because Seq exposes its OTLP endpoint over HTTP.

Step 1: Start Jaeger and Seq with Docker

You need somewhere to send the data. Both tools run in one Docker command each. Let us start them first so they are ready.

# Jaeger: UI on 16686, OTLP on 4317 (gRPC) and 4318 (HTTP)
docker run -d --name jaeger \
  -p 16686:16686 \
  -p 4317:4317 \
  -p 4318:4318 \
  jaegertracing/all-in-one:latest
 
# Seq: UI and ingestion on 5341, with the EULA accepted
docker run -d --name seq \
  -e ACCEPT_EULA=Y \
  -p 5341:5341 \
  datalust/seq:latest

Once they are running:

  • Open Jaeger at http://localhost:16686
  • Open Seq at http://localhost:5341

Both pages will be mostly empty for now. That is fine. We have no data yet.

Step 2: Add the NuGet packages

Create a normal ASP.NET Core Web API (or use one you have). Then add the OpenTelemetry packages. The core package plus a few small helpers is all you need.

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

Here is what each one does, in plain words:

PackageWhy you need it
OpenTelemetry.Extensions.HostingPlugs OpenTelemetry into the .NET host and DI
OpenTelemetry.Exporter.OpenTelemetryProtocolThe OTLP exporter that ships data out
OpenTelemetry.Instrumentation.AspNetCoreTraces incoming HTTP requests automatically
OpenTelemetry.Instrumentation.HttpTraces outgoing HttpClient calls automatically

The two instrumentation packages are the magic. They hook into ASP.NET Core and HttpClient so that every incoming request and every outgoing call becomes a span without you writing any tracing code by hand.

Step 3: Wire it up in Program.cs

Now we connect everything in Program.cs. We tell OpenTelemetry our service name, turn on tracing and metrics, add the instrumentation, and point the OTLP exporter at Jaeger.

using OpenTelemetry.Metrics;
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;
 
var builder = WebApplication.CreateBuilder(args);
 
// A name so we can tell this service apart from others.
var serviceName = "OrdersApi";
 
builder.Services.AddOpenTelemetry()
    .ConfigureResource(resource => resource.AddService(serviceName))
    .WithTracing(tracing => tracing
        .AddAspNetCoreInstrumentation()   // trace incoming requests
        .AddHttpClientInstrumentation()   // trace outgoing calls
        .AddOtlpExporter(otlp =>
        {
            // Jaeger's OTLP gRPC endpoint.
            otlp.Endpoint = new Uri("http://localhost:4317");
        }))
    .WithMetrics(metrics => metrics
        .AddAspNetCoreInstrumentation()
        .AddHttpClientInstrumentation()
        .AddOtlpExporter(otlp =>
        {
            otlp.Endpoint = new Uri("http://localhost:4317");
        }));
 
var app = builder.Build();
 
app.MapGet("/hello", () => "Hello from OpenTelemetry!");
 
app.Run();

Run the app, then call http://localhost:<your-port>/hello a few times. Wait a few seconds (the exporter sends in batches). Then open Jaeger at http://localhost:16686, pick OrdersApi in the service dropdown, and click Find Traces. You will see your requests as spans, with timing for each one.

That is the whole loop. Your code emitted a span, the OTLP exporter shipped it, and Jaeger drew it.

What a trace actually looks like

A trace is a tree of spans. The outer span is the whole request. Inner spans are the steps inside it. Each span knows its parent, so the backend can stack them into a waterfall.

Figure 2: One trace is a parent span with child spans for each step.

When you look at this in Jaeger, the slowest child jumps out at you. If "call PaymentApi" is 300 ms out of a 500 ms request, you know where to look. You did not have to guess or add print statements everywhere.

Step 4: Add your own spans

Automatic instrumentation covers HTTP. But sometimes you want to time your own logic, like a pricing calculation or a cache lookup. You do this with an ActivitySource. In .NET, an Activity is a span. The names are different for historical reasons, but they mean the same thing.

using System.Diagnostics;
 
// Create one shared source for your app. Give it the same name
// you register in OpenTelemetry below.
public static class Telemetry
{
    public static readonly ActivitySource Source = new("OrdersApi");
}
 
// Use it anywhere you want a custom span.
app.MapGet("/price/{id:int}", (int id) =>
{
    using var activity = Telemetry.Source.StartActivity("CalculatePrice");
    activity?.SetTag("order.id", id);
 
    // ...your real work here...
    var price = id * 100;
 
    activity?.SetTag("order.price", price);
    return Results.Ok(new { id, price });
});

For OpenTelemetry to pick up this source, register its name in the tracing setup:

.WithTracing(tracing => tracing
    .AddSource("OrdersApi")   // <-- matches the ActivitySource name
    .AddAspNetCoreInstrumentation()
    .AddHttpClientInstrumentation()
    .AddOtlpExporter(otlp => otlp.Endpoint = new Uri("http://localhost:4317")))

Now your CalculatePrice step shows up as a child span inside the request trace, with the tags order.id and order.price attached. Tags are like labels on the parcel: they help you search and understand later.

Step 5: Send logs and traces to Seq

Seq is wonderful for logs, and it also accepts traces. The nice part is that Seq stitches a log line to the trace it belongs to, so you can jump from a log message to the full request journey.

Seq listens for OTLP over HTTP at a special path: /ingest/otlp. So the trace endpoint is http://localhost:5341/ingest/otlp/v1/traces and the logs endpoint is http://localhost:5341/ingest/otlp/v1/logs. We must also tell the exporter to use HTTP/protobuf, because Seq does not use gRPC for this.

First, send logs to Seq through OpenTelemetry logging:

using OpenTelemetry.Exporter;
using OpenTelemetry.Logs;
 
builder.Logging.AddOpenTelemetry(logging =>
{
    logging.IncludeFormattedMessage = true;
    logging.IncludeScopes = true;
 
    logging.AddOtlpExporter(otlp =>
    {
        otlp.Endpoint = new Uri("http://localhost:5341/ingest/otlp/v1/logs");
        otlp.Protocol = OtlpExportProtocol.HttpProtobuf;
    });
});

You can send traces to Seq the same way, just with a different path and protocol. You are allowed to add more than one OTLP exporter, so you can send to Jaeger and Seq at the same time:

.WithTracing(tracing => tracing
    .AddSource("OrdersApi")
    .AddAspNetCoreInstrumentation()
    .AddHttpClientInstrumentation()
    // Exporter 1: Jaeger over gRPC
    .AddOtlpExporter(otlp =>
        otlp.Endpoint = new Uri("http://localhost:4317"))
    // Exporter 2: Seq over HTTP/protobuf
    .AddOtlpExporter(otlp =>
    {
        otlp.Endpoint = new Uri("http://localhost:5341/ingest/otlp/v1/traces");
        otlp.Protocol = OtlpExportProtocol.HttpProtobuf;
    }))

Now write a normal log line in any endpoint using the standard ILogger:

app.MapGet("/orders/{id:int}", (int id, ILogger<Program> logger) =>
{
    logger.LogInformation("Fetched order {OrderId}", id);
    return Results.Ok(new { id, status = "shipped" });
});

Call the endpoint, open Seq at http://localhost:5341, and you will see the log line Fetched order 7 with OrderId kept as a real field you can filter on. Because the log was created during a traced request, Seq also knows its TraceId, so logs and traces line up.

Where each tool shines

People sometimes ask, "Why send to two tools? Pick one." But they answer different questions. The picture below shows the split.

Figure 3: One app, two backends, each good at a different job.

Jaeger is the place you go when a request is slow and you want a visual waterfall. Seq is the place you go when you want to search: "show me every error for customer 42 in the last hour." Using both gives you the best of each, and OpenTelemetry makes that almost free, since you only add one more exporter line.

A clear decision path

When you set this up in a real project, you will make a few choices. Here is a simple way to decide.

Choosing your OpenTelemetry setup

Signal?
Transport?
Backend?
Done

Steps

1

Signal?

Need traces, metrics, logs, or all three

2

Transport?

gRPC 4317 by default, HTTP 4318 if blocked

3

Backend?

Jaeger for waterfalls, Seq for searching

4

Done

Add the matching OTLP exporter line

A short checklist to pick transport and backend.

Tips to avoid common beginner mistakes

A few small things trip up almost everyone the first time. Keep these in mind.

  • Match the source name. Your ActivitySource("OrdersApi") must match AddSource("OrdersApi"), or your custom spans never appear. The names are case sensitive.
  • Use the right port. Jaeger wants 4317 (gRPC) or 4318 (HTTP). Seq wants its OTLP path on 5341. Mixing them up is the number one reason data does not show.
  • Set the protocol for Seq. Seq needs OtlpExportProtocol.HttpProtobuf. If you forget it, the SDK defaults to gRPC and nothing arrives.
  • Wait a moment. The exporter sends in batches, not instantly. Give it a few seconds before you panic.
  • Give the service a name. Without AddService("OrdersApi"), your data shows up as unknown_service, and you cannot tell apps apart.

For configuration that changes per environment, you can move the endpoints into appsettings.json or environment variables instead of hard-coding them. The standard variables are OTEL_EXPORTER_OTLP_ENDPOINT and OTEL_EXPORTER_OTLP_PROTOCOL. This way, your dev machine can point at local Docker while production points at a real collector, with no code change.

A note on the bigger picture

You may later hear about the OpenTelemetry Collector. It is a separate program that sits between your apps and your backends. Your apps send to the collector, and the collector fans the data out to Jaeger, Seq, or anything else. For a first project you do not need it; sending straight to a backend is simpler. But it is good to know it exists, because larger systems use it to add one central place for filtering, sampling, and routing telemetry.

Also worth knowing: OpenTelemetry is a community standard, so it does not lock you into one vendor. Some older .NET libraries (for example MediatR and MassTransit) have moved to commercial licenses recently. OpenTelemetry is not one of them. The core libraries and the OTLP exporter are open source and free, which is a big reason it has become the default choice for observability in .NET.

Quick recap

  • OpenTelemetry is like a tracking number for your requests. It records the journey as traces (made of spans), plus metrics and logs.
  • The OTLP protocol is the shared language for shipping telemetry. It travels over gRPC (4317) or HTTP/protobuf (4318).
  • Add four packages, call AddOpenTelemetry(), turn on tracing and metrics, and add instrumentation for ASP.NET Core and HttpClient.
  • Point one OTLP exporter at Jaeger (port 4317) to get trace waterfalls.
  • Point another OTLP exporter at Seq (/ingest/otlp on 5341, HTTP/protobuf) to search logs and traces together.
  • Use an ActivitySource to add your own spans with tags, and remember to AddSource the same name.
  • Common mistakes: wrong port, missing protocol for Seq, mismatched source name, and forgetting the service name.
  • You can send to both Jaeger and Seq at once by adding two exporter lines. No vendor lock-in, and the libraries are free.

References and further reading

Related Posts