Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
264 changes: 264 additions & 0 deletions src/dotnet/skills/configuring-opentelemetry-dotnet/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,264 @@
```skill
---
name: configuring-opentelemetry-dotnet
description: Configure OpenTelemetry distributed tracing, metrics, and logging in ASP.NET Core using the .NET OpenTelemetry SDK. Use when adding observability, setting up OTLP exporters, creating custom metrics/spans, or troubleshooting distributed trace correlation.
---

Comment on lines +1 to +6

Copilot AI Feb 23, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SKILL.md is wrapped in a fenced ```skill code block, but the skill validator only parses YAML frontmatter when the file starts with ---. As written, metadata (especially `description`) won’t be detected and the whole document will be treated as code fencing. Remove the outer code fence and use normal top-of-file YAML frontmatter like the other skills.

Copilot uses AI. Check for mistakes.
# Configuring OpenTelemetry in .NET

## When to Use

- Adding distributed tracing to an ASP.NET Core application
- Setting up OpenTelemetry exporters (OTLP, Jaeger, Prometheus)
- Creating custom metrics or trace spans for business operations
- Troubleshooting distributed trace context propagation across services

## When Not to Use

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
- The user's application doesn't use ASP.NET

- The user wants application-level logging only (use ILogger, Serilog)
- The user is using Application Insights SDK directly (different API)
- The user needs APM with a commercial vendor's proprietary SDK

## Inputs

| Input | Required | Description |
|-------|----------|-------------|
| ASP.NET Core project | Yes | The application to instrument |
| Observability backend | No | Where to export: Jaeger, Prometheus, OTLP collector, Aspire |

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
| Observability backend | No | Where to export: Jaeger, Prometheus, OTLP collector, Aspire |
| Observability backend | No | Where to export: Jaeger, Prometheus, OTLP collector, Aspire Dashboard |


## Workflow

### Step 1: Install the correct packages

**There are many OpenTelemetry NuGet packages. Install exactly these:**

```bash
# Core SDK + ASP.NET Core instrumentation
dotnet add package OpenTelemetry.Extensions.Hosting
dotnet add package OpenTelemetry.Instrumentation.AspNetCore
dotnet add package OpenTelemetry.Instrumentation.Http

# Exporter (pick one or more)
dotnet add package OpenTelemetry.Exporter.OpenTelemetryProtocol # OTLP (recommended)
dotnet add package OpenTelemetry.Exporter.Console # Dev/debugging

# Optional: additional auto-instrumentation
dotnet add package OpenTelemetry.Instrumentation.SqlClient # SQL Server
dotnet add package OpenTelemetry.Instrumentation.EntityFrameworkCore # EF Core
dotnet add package OpenTelemetry.Instrumentation.GrpcNetClient # gRPC
```

**Do NOT install `OpenTelemetry` alone** — you need `OpenTelemetry.Extensions.Hosting` for proper DI integration.

### Step 2: Configure tracing in Program.cs

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

var builder = WebApplication.CreateBuilder(args);

// Define the service resource (appears in all telemetry)
var serviceName = "MyOrderService";
var serviceVersion = "1.0.0";

builder.Services.AddOpenTelemetry()
.ConfigureResource(resource => resource
.AddService(serviceName: serviceName, serviceVersion: serviceVersion))
.WithTracing(tracing => tracing
// Auto-instrumentation sources
.AddAspNetCoreInstrumentation(options =>
{
// Filter out health check endpoints from traces
options.Filter = httpContext =>
!httpContext.Request.Path.StartsWithSegments("/healthz");
})
.AddHttpClientInstrumentation(options =>
{
// Enrich outgoing HTTP spans with request/response details
options.RecordException = true;
})
.AddSqlClientInstrumentation(options =>
{
options.SetDbStatementForText = true; // Capture SQL text

@tarekgh tarekgh Feb 24, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

options.RecordException = true;
})
// Custom activity sources (for your own spans)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Many apps probably have no need to do this. You might want to segregate this into another step that is clearly optional.

.AddSource("MyOrderService.Orders")
.AddSource("MyOrderService.Payments")
// Exporter
.AddOtlpExporter(options =>
{
options.Endpoint = new Uri("http://localhost:4317"); // gRPC endpoint
// For HTTP: options.Protocol = OtlpExportProtocol.HttpProtobuf;
// options.Endpoint = new Uri("http://localhost:4318/v1/traces");
}))
.WithMetrics(metrics => metrics
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddRuntimeInstrumentation() // GC, thread pool metrics

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

does it need to reference OpenTelemetry.Instrumentation.Runtime package?

// Custom meters
.AddMeter("MyOrderService.Metrics")
.AddOtlpExporter());

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

these doesn't need to configure the endpoint as you did with metrics?

```

### Step 3: Add OpenTelemetry logging integration

```csharp
// Connect ILogger to OpenTelemetry
builder.Logging.AddOpenTelemetry(logging =>
{
logging.IncludeScopes = true;
logging.IncludeFormattedMessage = true;
logging.AddOtlpExporter();
});
```

**This correlates logs with traces automatically** — each log entry gets the current TraceId and SpanId.

### Step 4: Create custom spans (Activities) for business operations

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd recommend marking this optional. I suspect many customers don't need this.


```csharp
using System.Diagnostics;

public class OrderService
{
// Create an ActivitySource matching what you registered in Step 2
private static readonly ActivitySource ActivitySource = new("MyOrderService.Orders");

public async Task<Order> ProcessOrderAsync(CreateOrderRequest request)
{
// Start a new span
using var activity = ActivitySource.StartActivity("ProcessOrder");

// Add attributes (tags) to the span
activity?.SetTag("order.customer_id", request.CustomerId);
activity?.SetTag("order.item_count", request.Items.Count);

try
{
// Child span for validation
using (var validationActivity = ActivitySource.StartActivity("ValidateOrder"))
{
await ValidateOrderAsync(request);
validationActivity?.SetTag("validation.result", "passed");
}

// Child span for payment
using (var paymentActivity = ActivitySource.StartActivity("ProcessPayment",
ActivityKind.Client)) // Client = outgoing call
{
paymentActivity?.SetTag("payment.method", request.PaymentMethod);
await ProcessPaymentAsync(request);
}

activity?.SetTag("order.status", "completed");
activity?.SetStatus(ActivityStatusCode.Ok);

return order;
}
Comment on lines +157 to +161

Copilot AI Feb 23, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the OrderService example, return order; references an order variable that isn’t defined in the snippet, so the sample won’t compile. Either construct the order object in the example or return an existing variable (and keep the focus on tracing).

Copilot uses AI. Check for mistakes.
catch (Exception ex)
{
// Record the exception on the span
activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
activity?.RecordException(ex);
Comment on lines +158 to +166

Copilot AI Feb 23, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the spans example, SetStatus(...) and RecordException(...) are OpenTelemetry extension methods on Activity (namespace OpenTelemetry.Trace). The snippet only includes using System.Diagnostics;, so it won’t compile as-is. Add using OpenTelemetry.Trace; (or qualify the extension methods) in that example.

Suggested change
activity?.SetStatus(ActivityStatusCode.Ok);
return order;
}
catch (Exception ex)
{
// Record the exception on the span
activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
activity?.RecordException(ex);
if (activity != null)
{
OpenTelemetry.Trace.ActivityExtensions.SetStatus(activity, ActivityStatusCode.Ok);
}
return order;
}
catch (Exception ex)
{
// Record the exception on the span
if (activity != null)
{
OpenTelemetry.Trace.ActivityExtensions.SetStatus(activity, ActivityStatusCode.Error, ex.Message);
OpenTelemetry.Trace.ActivityExtensions.RecordException(activity, ex);
}

Copilot uses AI. Check for mistakes.
throw;
}
}
}
```

**Critical: `ActivitySource` name must match `AddSource("...")` in configuration.** Unmatched sources are silently ignored — this is the #1 debugging issue.

### Step 5: Create custom metrics

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should also be optional, though probably more apps would benefit from it than customer tracing spans.


```csharp
using System.Diagnostics.Metrics;

public class OrderMetrics
{
// Meter name must match AddMeter("...") in configuration
private static readonly Meter Meter = new("MyOrderService.Metrics");

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The official guidance is for apps using DI to use IMeterFactory rather than creating a static Meter:
https://learn.microsoft.com/en-us/aspnet/core/log-mon/metrics/metrics?view=aspnetcore-10.0#creating-metrics-in-aspnet-core-apps-with-imeterfactory


// Counter — use for things that only go up
private static readonly Counter<long> OrdersProcessed =
Meter.CreateCounter<long>("orders.processed", "orders",
"Total orders successfully processed");

// Histogram — use for measuring distributions (latency, sizes)
private static readonly Histogram<double> OrderProcessingDuration =
Meter.CreateHistogram<double>("orders.processing_duration", "ms",
"Time to process an order");

// UpDownCounter — use for things that go up AND down
private static readonly UpDownCounter<int> ActiveOrders =
Meter.CreateUpDownCounter<int>("orders.active", "orders",
"Currently processing orders");

// ObservableGauge — use for point-in-time values (queue depth, etc.)
// Note: registered once, callback invoked on each collection
private static readonly ObservableGauge<int> QueueDepth =
Meter.CreateObservableGauge("orders.queue_depth", () => GetQueueDepth());

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I assume GetQueueDepth just demonstrating the idea and doesn't have to exist in the code.


public void RecordOrderProcessed(string region, double durationMs)
{
// Tags enable dimensional filtering (by region, status, etc.)
var tags = new TagList
{
{ "region", region },
{ "order.type", "standard" }
};

OrdersProcessed.Add(1, tags);
OrderProcessingDuration.Record(durationMs, tags);
}
}
```

### Step 6: Configure context propagation for distributed scenarios

Trace context propagation is automatic for HTTP calls when using `AddHttpClientInstrumentation()`. For non-HTTP scenarios:

```csharp
// Manual context propagation (e.g., across message queues)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we need using directives here?

// On the SENDING side:
var propagator = Propagators.DefaultTextMapPropagator;
var context = new PropagationContext(Activity.Current!.Context, Baggage.Current);
var carrier = new Dictionary<string, string>();

propagator.Inject(context, carrier, (dict, key, value) => dict[key] = value);
// Send carrier dictionary as message headers

// On the RECEIVING side:
var parentContext = propagator.Extract(default, carrier,
(dict, key) => dict.TryGetValue(key, out var value) ? new[] { value } : Array.Empty<string>());

Baggage.Current = parentContext.Baggage;
using var activity = ActivitySource.StartActivity("ProcessMessage",
ActivityKind.Consumer,
parentContext.ActivityContext); // Links to parent trace!
```

## Validation

- [ ] Traces appear in the observability backend (Jaeger, Aspire dashboard, etc.)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And logs/metrics?

- [ ] HTTP requests automatically create spans with correct verb, URL, status code
- [ ] Custom `ActivitySource` names match `AddSource()` registrations
- [ ] Custom `Meter` names match `AddMeter()` registrations
- [ ] Logs include TraceId and SpanId for correlation
- [ ] Health check endpoints are filtered from traces
- [ ] Exception details appear on error spans

## Common Pitfalls

| Pitfall | Solution |
|---------|----------|
| `ActivitySource.StartActivity` returns null | Source name doesn't match any `AddSource()` — names must match exactly |
| Traces not appearing in exporter | Check OTLP endpoint: gRPC uses port 4317, HTTP uses 4318 |
| Missing HTTP client spans | `AddHttpClientInstrumentation()` only works with `IHttpClientFactory`-created clients |

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this accurate? wouldn't the instrumentation still work with the spans created from clients not created from IHttpClientFactory?

@rajkumar-rangaraj may help confirming or correcting.

| High cardinality tags | Don't use user IDs, request IDs, or UUIDs as metric tags — explodes storage |
| OTLP gRPC vs HTTP mismatch | Default is gRPC (port 4317); if collector only accepts HTTP, set `OtlpExportProtocol.HttpProtobuf` |
| `Meter` and `ActivitySource` not static | Must be static — creating per-request wastes memory and may lose data |

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMeterFactory again :)

```
40 changes: 40 additions & 0 deletions src/dotnet/tests/configuring-opentelemetry-dotnet/eval.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
scenarios:
- name: "Add OpenTelemetry tracing and metrics to an ASP.NET Core API"
prompt: |
I need to add distributed tracing and custom metrics to my ASP.NET Core 8 API. We use an OTLP-compatible collector (Jaeger). I want to:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd recommend either adding more eval prompts, or if we will only have a limited number of prompts focus on prompts that are simpler and more generalized. I'd guess prompts like these are going to be more common:
"Please enable telemetry for my app"
"Help set up OpenTelemetry"
"I want to record some metrics, how do I do that?"

Regardless of whether those imprecise prompts are best-practice, its what I would expect real users will frequently do.

1. Auto-instrument incoming HTTP requests and outgoing HttpClient calls
2. Create custom spans for my order processing business logic using ActivitySource
3. Add a custom counter metric for orders processed using System.Diagnostics.Metrics.Meter

Show me the full setup including package installation, Program.cs configuration, and how to create the custom spans and metrics in my service class. I want to make sure my custom traces and metrics actually show up in Jaeger — last time I tried, the auto-instrumentation worked but my custom spans were invisible.
assertions:
- type: "output_matches"
pattern: "(OpenTelemetry\\.Extensions\\.Hosting|AddOpenTelemetry)"
- type: "output_matches"
pattern: "(ActivitySource|StartActivity)"
- type: "output_matches"
pattern: "(AddSource|AddMeter)"
- type: "output_matches"
pattern: "(Counter|Histogram|CreateCounter|CreateHistogram)"
rubric:
- "Installed the correct packages: OpenTelemetry.Extensions.Hosting, OpenTelemetry.Instrumentation.AspNetCore, OpenTelemetry.Exporter.OpenTelemetryProtocol"
- "Configured AddOpenTelemetry() with .WithTracing() and .WithMetrics() in Program.cs"
- "Added AddAspNetCoreInstrumentation() and AddHttpClientInstrumentation() for auto-instrumentation"
- "Registered custom ActivitySource names via AddSource() matching the source name used in the service class"
- "Created custom spans using ActivitySource.StartActivity() with proper using/dispose pattern"
- "Created custom metrics using Meter.CreateCounter() or similar, with the Meter name matching AddMeter()"
- "Explicitly warned that ActivitySource/Meter names must match AddSource/AddMeter registrations exactly or custom telemetry will be silently dropped"
expect_tools: ["bash"]
timeout: 120

- name: "OpenTelemetry skill should not activate for simple logging question"
prompt: "How do I configure Serilog structured logging in my ASP.NET Core app to write to a file and console?"
assertions:
- type: "output_not_contains"
value: "OpenTelemetry"
- type: "output_not_matches"
pattern: "(ActivitySource|StartActivity|AddOpenTelemetry|OTLP)"
rubric:
- "Did NOT suggest OpenTelemetry for a Serilog logging question"
- "Provided Serilog-specific configuration guidance"
timeout: 60