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.
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.
A quick way to remember what each one is for:
| Signal | What it answers | Example |
|---|---|---|
| Trace | Where did the time go for one request? | "Checkout took 800 ms, 700 ms was the database" |
| Metric | How is the whole system doing? | "We serve 50 requests per second" |
| Log | What 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
Steps
Instrument
Your code emits spans, metrics, logs
Collect
The OTel SDK batches the data
Export
OTLP sends it over gRPC or HTTP
Ingest
Jaeger or Seq receives it
View
You read the timeline or search logs
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:
| Transport | Default port | When to use |
|---|---|---|
| gRPC | 4317 | Default on .NET 8+, fast and compact |
| HTTP/protobuf | 4318 | Easy 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:latestOnce 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.HttpHere is what each one does, in plain words:
| Package | Why you need it |
|---|---|
OpenTelemetry.Extensions.Hosting | Plugs OpenTelemetry into the .NET host and DI |
OpenTelemetry.Exporter.OpenTelemetryProtocol | The OTLP exporter that ships data out |
OpenTelemetry.Instrumentation.AspNetCore | Traces incoming HTTP requests automatically |
OpenTelemetry.Instrumentation.Http | Traces 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.
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.
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
Steps
Signal?
Need traces, metrics, logs, or all three
Transport?
gRPC 4317 by default, HTTP 4318 if blocked
Backend?
Jaeger for waterfalls, Seq for searching
Done
Add the matching OTLP exporter line
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 matchAddSource("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 asunknown_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 andHttpClient. - Point one OTLP exporter at Jaeger (port 4317) to get trace waterfalls.
- Point another OTLP exporter at Seq (
/ingest/otlpon 5341, HTTP/protobuf) to search logs and traces together. - Use an
ActivitySourceto add your own spans with tags, and remember toAddSourcethe 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
- OpenTelemetry .NET — official documentation
- OpenTelemetry .NET — Export to Jaeger
- opentelemetry-dotnet — Getting started with Jaeger (GitHub)
- Seq — Tracing from OpenTelemetry SDKs
- Seq — Logging from OpenTelemetry
- Microsoft Learn — Use OpenTelemetry with Prometheus, Grafana, and Jaeger
- Anton Martyniuk — Getting started with OpenTelemetry in .NET with Jaeger and Seq
Related Posts
5 Serilog Best Practices for Better Structured Logging in .NET
Learn 5 simple Serilog best practices for structured logging in .NET: message templates, enrichers, correlation IDs, hiding secrets, and async sinks.
Better Request Tracing with User Context in ASP.NET Core
Learn how to trace requests in ASP.NET Core by adding user context and correlation IDs to your logs using middleware, logging scopes, and Activity.
.NET Aspire: A Game Changer for Cloud-Native Development
A beginner-friendly guide to .NET Aspire, the cloud-native stack that orchestrates your services, databases, and dashboards with one simple command.
Introduction to Distributed Tracing With OpenTelemetry in .NET
A beginner-friendly guide to distributed tracing in .NET with OpenTelemetry. Learn traces, spans, context propagation, and how to add them step by step.
Monitoring .NET Applications With OpenTelemetry and Grafana
A beginner-friendly guide to monitoring .NET apps with OpenTelemetry and Grafana. Send metrics, traces, and logs to Prometheus, Tempo, and Loki step by step.
Logging Best Practices in ASP.NET Core: A Beginner's Guide
Learn logging best practices in ASP.NET Core: log levels, structured logging, source-generated LoggerMessage, scopes, correlation IDs, and keeping secrets out.