# OpenTelemetry for .NET: Traces, Metrics & Logs Guide

> Instrument .NET apps with OpenTelemetry - ActivitySource traces, Meter metrics, ILogger logs - and ship everything to OpenObserve over OTLP.

Source: https://openobserve.ai/opentelemetry/dotnet/
Published: 2026-07-08
Language: .NET

---

OpenTelemetry .NET is unusual among the language SDKs: instead of shipping its own tracing and metrics APIs, it builds on primitives that live in the .NET runtime itself - `System.Diagnostics.ActivitySource` for traces, `System.Diagnostics.Metrics.Meter` for metrics, and `Microsoft.Extensions.Logging.ILogger` for logs. That design has a practical payoff: libraries across the .NET ecosystem (including ASP.NET Core, `HttpClient`, and Entity Framework Core) already emit telemetry through these APIs, and the OpenTelemetry SDK simply collects and exports it.

This guide covers instrumenting a .NET application with the OpenTelemetry SDK - automatic and manual, all three signals - and shipping the data to OpenObserve over OTLP. If you are new to OpenTelemetry concepts like spans, resources, and OTLP, start with [What is OpenTelemetry?](/blog/what-is-opentelemetry/) and come back.

## Why OpenTelemetry for .NET

- **All three signals are stable.** OpenTelemetry .NET has shipped stable traces, metrics, and logs. There is no experimental caveat to work around - you can standardize your whole telemetry pipeline on it today.
- **Runtime-native APIs.** Because instrumentation uses `ActivitySource`, `Meter`, and `ILogger`, your code takes no hard dependency on OpenTelemetry packages except at the composition root. Libraries you write stay vendor-neutral and OTel-free.
- **Vendor neutrality.** Export over OTLP to OpenObserve, or to any other OTLP-compatible backend, by changing configuration - not code.
- **A real zero-code option.** The OpenTelemetry .NET Automatic Instrumentation project bootstraps the managed SDK via the DOTNET_STARTUP_HOOKS startup hook on .NET (Core/5+) and via the CLR Profiler on .NET Framework, instrumenting existing apps - including .NET Framework apps - without a rebuild.

| Signal  | .NET API surface                        | Status |
| ------- | --------------------------------------- | ------ |
| Traces  | `System.Diagnostics.ActivitySource`     | Stable |
| Metrics | `System.Diagnostics.Metrics.Meter`      | Stable |
| Logs    | `Microsoft.Extensions.Logging.ILogger`  | Stable |

Everything below works with any .NET application model - ASP.NET Core, worker services, console apps, gRPC services. The examples use a minimal ASP.NET Core app only because it is the shortest way to show a runnable host.

## Prerequisites

- **.NET 8 or later** for the SDK examples (the SDK also supports .NET Framework 4.6.2+ and .NET Standard 2.0 targets; automatic instrumentation supports .NET Framework 4.6.2+).
- **An OpenObserve instance**:
  - **Cloud:** sign up at OpenObserve Cloud and grab your organization name and ingestion credentials from **Data Sources** in the UI.
  - **Self-hosted:** a single binary or container is enough. `docker run -p 5080:5080 public.ecr.aws/zinclabs/openobserve:latest` gets you a local instance at `http://localhost:5080`.
- **Your Basic auth token.** OpenObserve authenticates OTLP ingestion with HTTP Basic auth:

```bash
echo -n 'root@example.com:your-password' | base64
   # output: cm9vdEBleGFtcGxlLmNvbTp5b3VyLXBhc3N3b3Jk
```

Keep that value handy; it goes into the `Authorization: Basic <token>` header on every export.

## Zero-code: automatic instrumentation

If you want telemetry from an existing service without touching source, use [OpenTelemetry .NET Automatic Instrumentation](https://opentelemetry.io/docs/zero-code/dotnet/). On .NET (Core/5+) it bootstraps the managed SDK via the DOTNET_STARTUP_HOOKS startup hook, with the CoreCLR Profiler (CORECLR_PROFILER) used for additional monkey-patching; on .NET Framework it attaches via the CLR Profiler (COR_PROFILER). It injects instrumentation at runtime and covers popular libraries (ASP.NET Core, HttpClient, SQL clients, and more) out of the box.

**Linux/macOS:**

```bash
curl -sSfL https://github.com/open-telemetry/opentelemetry-dotnet-instrumentation/releases/latest/download/otel-dotnet-auto-install.sh -O
sh ./otel-dotnet-auto-install.sh          # download and install the latest release

chmod +x $HOME/.otel-dotnet-auto/instrument.sh
. $HOME/.otel-dotnet-auto/instrument.sh   # enable it in the current shell

export OTEL_SERVICE_NAME="checkout-api"
export OTEL_EXPORTER_OTLP_ENDPOINT="http://localhost:5080/api/default"
export OTEL_EXPORTER_OTLP_PROTOCOL="http/protobuf"
export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Basic cm9vdEBleGFtcGxlLmNvbTp5b3VyLXBhc3N3b3Jk"
dotnet run
```

**Windows (PowerShell, as Administrator):**

```powershell
$module_url = "https://github.com/open-telemetry/opentelemetry-dotnet-instrumentation/releases/latest/download/OpenTelemetry.DotNet.Auto.psm1"
$download_path = Join-Path $env:temp "OpenTelemetry.DotNet.Auto.psm1"
Invoke-WebRequest -Uri $module_url -OutFile $download_path -UseBasicParsing
Import-Module $download_path
Install-OpenTelemetryCore
Register-OpenTelemetryForCurrentSession -OTelServiceName "checkout-api"
```

There are also dedicated helpers for Windows Services (`Register-OpenTelemetryForWindowsService`) and IIS (`Register-OpenTelemetryForIIS`), which makes this the most practical route for legacy .NET Framework applications.

Automatic instrumentation gets you request-level traces and runtime metrics quickly, but it cannot know your business logic. For spans around your own operations, custom metrics, and structured logs, add manual instrumentation - the two approaches compose cleanly, since the auto-instrumentation is built on the same SDK.

## Manual instrumentation

Install the core packages:

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

`OpenTelemetry.Extensions.Hosting` wires the SDK into the generic host and dependency injection; it pulls in the core `OpenTelemetry` SDK package transitively. `OpenTelemetry.Exporter.OpenTelemetryProtocol` is the OTLP exporter used for all three signals. The three instrumentation packages light up incoming HTTP server spans and request-duration metrics, outgoing `HttpClient` spans, and .NET runtime metrics (GC, thread pool, JIT) respectively.

### Traces

In .NET, a span is an `Activity` and a tracer is an `ActivitySource`. You create the source once (typically as a `static readonly` field), register its name with the SDK, and start activities around interesting work:

```csharp
using System.Diagnostics;
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;

var builder = WebApplication.CreateBuilder(args);

// One ActivitySource per component/assembly. Reuse it; never create per-request.
var activitySource = new ActivitySource("Shop.Checkout");

builder.Services.AddOpenTelemetry()
    .ConfigureResource(resource => resource
        .AddService(serviceName: "checkout-api", serviceVersion: "1.4.2"))
    .WithTracing(tracing => tracing
        .AddSource("Shop.Checkout")          // must match the ActivitySource name
        .AddAspNetCoreInstrumentation()      // incoming HTTP spans
        .AddHttpClientInstrumentation()      // outgoing HTTP spans
        .AddOtlpExporter());                 // endpoint/headers come from env vars

var app = builder.Build();

app.MapGet("/checkout/{orderId}", async (string orderId) =>
{
    // Child span of the ASP.NET Core request span, automatically parented
    using var activity = activitySource.StartActivity("ProcessOrder");
    activity?.SetTag("order.id", orderId);

    try
    {
        await Task.Delay(50); // pretend to reserve inventory, charge card...
        activity?.SetTag("order.items", 3);
        activity?.SetStatus(ActivityStatusCode.Ok);
        return Results.Ok(new { orderId, status = "confirmed" });
    }
    catch (Exception ex)
    {
        activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
        activity?.AddException(ex);
        throw;
    }
});

app.Run();
```

Two things trip people up here. First, `StartActivity` returns `null` when no listener is sampling that source - hence the `activity?.` null-conditional calls; they are idiomatic, not defensive clutter. Second, `.AddSource("Shop.Checkout")` is mandatory: if the SDK is not subscribed to your `ActivitySource` name, your custom spans silently go nowhere. Wildcards like `.AddSource("Shop.*")` are supported.

### Metrics

Metrics use `System.Diagnostics.Metrics.Meter`, also part of the runtime. Create instruments once and record measurements on the hot path:

```csharp
using System.Diagnostics.Metrics;
using OpenTelemetry.Metrics;
using OpenTelemetry.Resources;

var builder = WebApplication.CreateBuilder(args);

var meter = new Meter("Shop.Checkout", "1.0.0");
var ordersProcessed = meter.CreateCounter<long>(
    "shop.orders.processed",
    unit: "{order}",
    description: "Number of orders processed");
var checkoutDuration = meter.CreateHistogram<double>(
    "shop.checkout.duration",
    unit: "ms",
    description: "Time taken to process a checkout");

builder.Services.AddOpenTelemetry()
    .ConfigureResource(resource => resource
        .AddService(serviceName: "checkout-api", serviceVersion: "1.4.2"))
    .WithMetrics(metrics => metrics
        .AddMeter("Shop.Checkout")           // must match the Meter name
        .AddAspNetCoreInstrumentation()      // http.server.request.duration etc.
        .AddRuntimeInstrumentation()         // GC, thread pool, exceptions
        .AddOtlpExporter());

var app = builder.Build();

app.MapGet("/checkout/{orderId}", (string orderId) =>
{
    var start = TimeProvider.System.GetTimestamp();

    // ... process the order ...

    var elapsed = TimeProvider.System.GetElapsedTime(start);
    ordersProcessed.Add(1, new KeyValuePair<string, object?>("payment.method", "card"));
    checkoutDuration.Record(elapsed.TotalMilliseconds);
    return Results.Ok(new { orderId });
});

app.Run();
```

The same registration rule applies: `.AddMeter("Shop.Checkout")` must match your `Meter` name or measurements are dropped. Keep attribute cardinality low - attribute values like user IDs or order IDs on a counter will explode your time series count.

### Logs

Logging is where .NET's integration is the tightest: OpenTelemetry hooks into `Microsoft.Extensions.Logging`, so every existing `ILogger` call in your app and your dependencies flows through the OTel pipeline unchanged. Structured template properties become log record attributes, and when a log is written inside an active span, the trace ID and span ID are attached automatically - that is what makes log-to-trace correlation work in OpenObserve.

```csharp
using OpenTelemetry.Logs;
using OpenTelemetry.Resources;

var builder = WebApplication.CreateBuilder(args);

builder.Logging.AddOpenTelemetry(logging =>
{
    logging.SetResourceBuilder(ResourceBuilder.CreateDefault()
        .AddService(serviceName: "checkout-api", serviceVersion: "1.4.2"));
    logging.IncludeScopes = true;            // capture ILogger scopes as attributes
    logging.IncludeFormattedMessage = true;  // keep the rendered message text
    logging.AddOtlpExporter();
});

var app = builder.Build();

app.MapGet("/checkout/{orderId}", (string orderId, ILogger<Program> logger) =>
{
    // Structured properties, not string interpolation:
    logger.LogInformation("Processing order {OrderId} with {ItemCount} items",
        orderId, 3);
    return Results.Ok(new { orderId });
});

app.Run();
```

Use message templates (`{OrderId}`), not interpolated strings (`$"...{orderId}..."`). Templates preserve properties as queryable attributes and keep the message text low-cardinality; interpolation collapses everything into an opaque string. For a deeper treatment - log levels, scopes, correlation, and querying .NET logs in OpenObserve - see [OpenTelemetry Logging in .NET Applications](/blog/opentelemetry-logging-in-dotnet-applications/).

### All three signals together

In a real service you configure everything once at startup:

```csharp
using OpenTelemetry.Logs;
using OpenTelemetry.Metrics;
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddOpenTelemetry()
    .ConfigureResource(r => r.AddService("checkout-api", serviceVersion: "1.4.2"))
    .WithTracing(t => t
        .AddSource("Shop.Checkout")
        .AddAspNetCoreInstrumentation()
        .AddHttpClientInstrumentation()
        .AddOtlpExporter())
    .WithMetrics(m => m
        .AddMeter("Shop.Checkout")
        .AddAspNetCoreInstrumentation()
        .AddRuntimeInstrumentation()
        .AddOtlpExporter());

builder.Logging.AddOpenTelemetry(logging =>
{
    logging.IncludeScopes = true;
    logging.AddOtlpExporter();
});

var app = builder.Build();
app.MapGet("/", () => "ok");
app.Run();
```

Note that `AddOtlpExporter()` takes no arguments here. That is deliberate: the exporter reads the standard `OTEL_EXPORTER_OTLP_*` environment variables, which keeps credentials and endpoints out of your source code.

## Sending data to OpenObserve

OpenObserve ingests OTLP natively - no collector required in between, though you can add one later for fan-out or processing (see [A Beginner's Guide to OTLP Exporters](/blog/otel-exporters-introduction/) for when that makes sense).

Two transports are supported:

- **OTLP/HTTP (protobuf)** on the main HTTP port (5080 by default self-hosted). Signal paths follow the pattern `/api/<org>/v1/traces`, `/api/<org>/v1/metrics`, `/api/<org>/v1/logs`.
- **OTLP/gRPC** on port 5081 (self-hosted default, configurable via `ZO_GRPC_PORT`), which additionally requires an `organization` header.

For most .NET deployments, OTLP/HTTP is the right default: it needs only the base endpoint plus an auth header, and the SDK appends the per-signal `/v1/...` path itself.

**Self-hosted OpenObserve:**

```bash
export OTEL_SERVICE_NAME="checkout-api"
export OTEL_RESOURCE_ATTRIBUTES="deployment.environment.name=production,service.version=1.4.2"
export OTEL_EXPORTER_OTLP_ENDPOINT="http://localhost:5080/api/default"
export OTEL_EXPORTER_OTLP_PROTOCOL="http/protobuf"
export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Basic cm9vdEBleGFtcGxlLmNvbTp5b3VyLXBhc3N3b3Jk"

dotnet run
```

**OpenObserve Cloud:**

```bash
export OTEL_SERVICE_NAME="checkout-api"
export OTEL_RESOURCE_ATTRIBUTES="deployment.environment.name=production,service.version=1.4.2"
export OTEL_EXPORTER_OTLP_ENDPOINT="https://api.openobserve.ai/api/<your-org>"
export OTEL_EXPORTER_OTLP_PROTOCOL="http/protobuf"
export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Basic <your-base64-token>"

dotnet run
```

Replace `<your-org>` and the token with the values shown under **Data Sources** in your OpenObserve Cloud UI, which displays copy-paste-ready OTLP settings for your account.

Three details matter:

1. **Do not end the endpoint with a trailing slash.** The exporter appends `/v1/traces` (and so on) to the base endpoint; a trailing slash produces a malformed path.
2. **`OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf` is required.** The .NET OTLP exporter defaults to gRPC. Without this variable it will try gRPC framing against the HTTP port and every export will fail.
3. **The header value format is `Authorization=Basic <token>`** - note `=` between the header name and value, and a literal space inside the value.

If you prefer configuring in code (for example, from `appsettings.json` via the options pattern), the equivalent is:

```csharp
using OpenTelemetry.Exporter;

.AddOtlpExporter(options =>
{
    // For HttpProtobuf, the .NET exporter uses the URI as-is,
    // so include the full per-signal path:
    options.Endpoint = new Uri("http://localhost:5080/api/default/v1/traces");
    options.Protocol = OtlpExportProtocol.HttpProtobuf;
    options.Headers = "Authorization=Basic cm9vdEBleGFtcGxlLmNvbTp5b3VyLXBhc3N3b3Jk";
})
```

Mind the asymmetry: with environment variables you supply the base endpoint and the SDK appends the signal path; with a programmatic `Endpoint` in `HttpProtobuf` mode you must supply the full path per signal. Environment variables are less error-prone - use them unless you have a reason not to.

## Verifying data arrival in OpenObserve

Generate some traffic first:

```bash
for i in $(seq 1 20); do curl -s http://localhost:8080/checkout/order-$i > /dev/null; done
```

Then check each signal in the OpenObserve UI:

1. **Traces:** open the **Traces** page, select your organization, and filter by service name (`checkout-api`). You should see the ASP.NET Core request spans with your custom `ProcessOrder` child spans nested inside; click one to see the flame graph, span attributes like `order.id`, and any recorded exceptions.
2. **Logs:** open **Logs** and select the `default` log stream (or whichever stream you configured). Log records carry your structured attributes plus `trace_id` and `span_id` fields when they were emitted inside a span, so you can pivot from a slow trace to exactly the logs it produced.
3. **Metrics:** open **Streams** and look for metric streams named after your instruments (for example, `shop_orders_processed` - OTLP metric names are normalized for storage). Query them from the Metrics explorer or a dashboard panel.

If nothing shows up within a minute, jump to the troubleshooting section - the cause is almost always one of five things.

## Production tips

**Batching is the default - keep it.** The SDK exports traces and logs through a `BatchExportProcessor` and metrics through a `PeriodicExportingMetricReader` out of the box when you use `AddOtlpExporter()`. Do not switch to simple/synchronous export in production; it turns every span end into a network call. Tune with the standard variables if needed (`OTEL_BSP_MAX_QUEUE_SIZE`, `OTEL_BSP_SCHEDULE_DELAY`, `OTEL_METRIC_EXPORT_INTERVAL`).

**Sample at the head, deliberately.** Full tracing of a high-throughput API is rarely worth the cost. The standard approach is parent-based ratio sampling, which keeps trace trees intact - if the incoming request was sampled, all its children are too:

```bash
export OTEL_TRACES_SAMPLER="parentbased_traceidratio"
export OTEL_TRACES_SAMPLER_ARG="0.1"   # keep 10% of root traces
```

Start at 100% in staging, then dial down in production based on volume.

**Invest in resource attributes.** `service.name`, `service.version`, and `deployment.environment` are the axes you will filter every query by. Set them once via `OTEL_SERVICE_NAME` / `OTEL_RESOURCE_ATTRIBUTES` so all three signals share identical resource identity - that consistency is what makes cross-signal correlation work.

**Flush on shutdown.** When you use `AddOpenTelemetry()` with the generic host, the SDK's `TracerProvider`, `MeterProvider`, and logger provider are disposed on graceful shutdown and flush pending telemetry. Two caveats: give the host time to drain (the default `HostOptions.ShutdownTimeout` may be tight if your OTLP endpoint is slow), and in short-lived console apps that build the SDK manually, explicitly call `tracerProvider.ForceFlush()` / `Dispose()` before exit or you will lose the tail of your data.

**Watch instrument and attribute cardinality.** The metrics SDK caps the number of attribute combinations per instrument (overflow measurements are folded into an overflow attribute). If a counter tagged with high-cardinality values seems to "stop counting," cardinality limits are why. Keep IDs out of metric attributes; put them on spans and logs instead.

**.NET-specific pitfalls:**

- Creating `ActivitySource` or `Meter` per request instead of once per process - both are designed to be long-lived statics.
- Forgetting `.AddSource(...)` / `.AddMeter(...)`: the runtime APIs no-op silently without a subscribed listener, so there is no exception to alert you.
- Assuming `Activity.Current` flows across manual thread hops. It flows with `async`/`await` via `AsyncLocal`, but fire-and-forget work queued without the execution context loses the current span.
- On .NET Framework or older libraries, `Activity.DefaultIdFormat` legacy hierarchical IDs can break context propagation; modern SDK versions default to W3C, but check if you see orphaned spans.

## Troubleshooting

**Symptom: every export fails, or the app logs gRPC/`HttpRequestException` errors, and nothing reaches OpenObserve.**
Fix: you are almost certainly sending gRPC to the HTTP port. Set `OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf` (the .NET exporter defaults to gRPC), or point gRPC exports at port 5081 instead of 5080.

**Symptom: HTTP 401 responses from OpenObserve.**
Fix: the Basic token is wrong or missing. Regenerate it with `echo -n 'email:password' | base64` - the `-n` matters; a trailing newline inside the encoded value breaks authentication. Confirm the header syntax is exactly `OTEL_EXPORTER_OTLP_HEADERS="Authorization=Basic <token>"`.

**Symptom: HTTP 404 responses from OpenObserve.**
Fix: check the endpoint path. It must include your organization (`/api/<org>`) and must not end with a trailing slash, since the SDK appends `/v1/traces` itself. A doubled path like `/api/default//v1/traces` or a missing org segment both 404.

**Symptom: auto-instrumented spans arrive, but your custom `ActivitySource` spans never do.**
Fix: the SDK is not listening to your source. Add `.AddSource("Your.Source.Name")` with the exact name (or a wildcard) to `WithTracing`. The same applies to metrics with `.AddMeter(...)`. Remember `StartActivity` returning `null` is the tell-tale sign no listener is sampling.

**Symptom: traces appear, but logs in OpenObserve have no `trace_id`, so correlation is broken.**
Fix: the log must be written while an `Activity` is current. Verify the log call happens inside the span's `using` scope and that tracing is actually configured in the same process. If you route logs through a third-party framework (Serilog, NLog) instead of `builder.Logging.AddOpenTelemetry(...)`, use that framework's OpenTelemetry sink/target or bridge back into `Microsoft.Extensions.Logging`.

**Symptom: telemetry from a short-lived console app or background job is missing its final spans.**
Fix: the batch processor buffered data that was never flushed. Dispose the `TracerProvider`/`MeterProvider` (or call `ForceFlush()`) before the process exits, and increase the host shutdown timeout if exports to a remote endpoint are slow.

**Symptom: a metric stream shows far fewer series than expected, or values pile up under an overflow attribute.**
Fix: you hit the SDK's cardinality limit for that instrument. Reduce distinct attribute values (drop per-user or per-request IDs from metric attributes) or raise the limit deliberately if the cardinality is genuinely needed.

With the SDK wired up and OTLP pointed at OpenObserve, you have a complete, stable, vendor-neutral observability pipeline for .NET - traces, metrics, and logs correlated end to end, with nothing proprietary baked into your application code.
