From dotnet-skills
Guides OpenTelemetry instrumentation in .NET codebases: tracing (Activities/Spans), metrics, naming conventions, error handling, performance, and API best practices.
npx claudepluginhub aaronontheweb/dotnet-skills --plugin dotnet-skillsThis skill uses the workspace's default tool permissions.
Provides guidance for implementing OpenTelemetry instrumentation in .NET codebases, covering tracing (Activities/Spans), metrics, naming conventions, error handling, performance, and API design best practices.
Creates isolated Git worktrees for feature branches with prioritized directory selection, gitignore safety checks, auto project setup for Node/Python/Rust/Go, and baseline verification.
Executes implementation plans in current session by dispatching fresh subagents per independent task, with two-stage reviews: spec compliance then code quality.
Dispatches parallel agents to independently tackle 2+ tasks like separate test failures or subsystems without shared state or dependencies.
Provides guidance for implementing OpenTelemetry instrumentation in .NET codebases, covering tracing (Activities/Spans), metrics, naming conventions, error handling, performance, and API design best practices.
CRITICAL: Exceptions in diagnostic/tracing/metrics logic MUST NEVER impact application processing.
activity?.ExtensionMethod())// ✅ CORRECT: Use ActivitySource, not DiagnosticSource
public class MyFeature
{
// Primary ActivitySource - name typically matches the component or NuGet package name
private static readonly ActivitySource ActivitySource = new("MyApp.MyComponent", "1.0.0");
// Specialized ActivitySource for opt-in scenarios
private static readonly ActivitySource DetailedActivitySource = new("MyApp.MyComponent.Detailed", "1.0.0");
}
Rules:
ActivitySource for mainstream activities"MyCompany.MyLibrary")// ✅ CORRECT: Check HasListeners before creating
if (ActivitySource.HasListeners())
{
using var activity = ActivitySource.StartActivity("ProcessItem", ActivityKind.Internal);
if (activity != null)
{
activity.DisplayName = "Processing order #12345";
// Only compute expensive tags if requested
if (activity.IsAllDataRequested)
{
activity.SetTag("app.item_id", itemId);
activity.SetTag("app.item_type", itemType);
}
}
}
// ❌ WRONG: Don't start activities in async helper methods (breaks AsyncLocal)
async Task HelperAsync()
{
using var activity = ActivitySource.StartActivity("Helper"); // ❌ BAD
await DoWorkAsync();
}
Rules:
ActivitySource.HasListeners() before creating (zero-allocation fast path)Activity.Current uses AsyncLocal)activity.IsAllDataRequested before expensive computations// ✅ CORRECT: Unique operation name, friendly display name
using var activity = ActivitySource.StartActivity(
name: "ProcessItem", // Unique, identifies class of spans
kind: ActivityKind.Internal
);
activity.DisplayName = "Processing order #12345"; // User-friendly, can be specific
// ❌ WRONG: Don't include runtime data in operation name
using var activity = ActivitySource.StartActivity($"Process_{itemId}"); // ❌ BAD
Rules:
OperationName (identifies statistically interesting class of spans)DisplayName for specifics// ✅ CORRECT: Namespace, lowercase, underscore-delimited
activity?.SetTag("myapp.order_id", orderId);
activity?.SetTag("myapp.order_type", orderType);
activity?.SetTag("myapp.db.table_name", tableName);
// Standard semantic conventions where applicable
activity?.SetTag("db.system", "postgresql");
activity?.SetTag("http.method", "GET");
// ❌ WRONG: Various naming violations
activity?.SetTag("MyApp.OrderId", orderId); // ❌ Wrong case
activity?.SetTag("myapp.order-id", orderId); // ❌ Wrong delimiter
activity?.SetTag("myapp.orders", count); // ❌ Plural
activity?.SetTag("unrelated.ip_address", ip); // ❌ Not characteristic
Naming Conventions:
myapp.*, myapp.db.*_) delimiters for multi-word attributes// ✅ CORRECT: Set status and record exceptions
try
{
await ProcessItemAsync();
activity?.SetStatus(ActivityStatusCode.Ok);
}
catch (Exception ex)
{
if (activity != null)
{
activity.SetStatus(ActivityStatusCode.Error);
activity.SetTag("otel.status_code", "error");
activity.SetTag("otel.status_description", ex.Message);
// Record exception event per OTel spec
activity.AddEvent(new ActivityEvent(
"exception",
tags: new ActivityTagsCollection
{
["exception.type"] = ex.GetType().FullName,
["exception.message"] = ex.Message,
["exception.stacktrace"] = ex.ToString()
}
));
}
throw;
}
Rules:
ActivityStatusCode.Ok on successActivityStatusCode.Error on exceptionotel.status_code and otel.status_description tags// ✅ CORRECT: Use events for additional context (sparingly)
activity?.AddEvent(new ActivityEvent("ItemRetried", tags: new ActivityTagsCollection
{
["retry_attempt"] = retryCount,
["next_retry_delay"] = delayMs
}));
// ❌ WRONG: Don't use events for verbose logging
activity?.AddEvent(new ActivityEvent($"Step {i} completed")); // ❌ Use logging instead
Rules:
// ❌ WRONG: Don't rely on Activity.Current when you need a specific span
public async Task HandleAsync(Context context)
{
var activity = Activity.Current; // ❌ Might be a user-created span, not yours
activity?.SetTag("custom", "value");
}
// ✅ CORRECT: Pass Activity explicitly or store it in a dedicated context object
public async Task HandleAsync(Context context)
{
if (context.TryGetActivity(out var activity))
{
activity?.SetTag("custom", "value");
}
}
// ✅ CORRECT: Group metrics by feature/component
public sealed class OrderProcessingMetrics : IDisposable
{
private readonly Meter meter;
private readonly Histogram<double> processingDuration;
private readonly Counter<long> itemsProcessed;
public OrderProcessingMetrics()
{
meter = new Meter("MyApp.OrderProcessing", "1.0.0");
// Singular names, appropriate units, nested hierarchy
processingDuration = meter.CreateHistogram<double>(
"myapp.order.processing.duration",
unit: "s",
description: "Duration of order processing"
);
itemsProcessed = meter.CreateCounter<long>(
"myapp.order.processing.count",
unit: "{order}",
description: "Number of orders processed"
);
}
public void Dispose() => meter.Dispose();
}
Naming Conventions (follow OTel semantic conventions):
_count suffix instead of pluralization)myapp.order.processing.duration_counter, _histogram)// ✅ CORRECT: Action/outcome-based naming, separate methods per outcome
public sealed class OrderProcessingMetrics
{
// Event happened: describe what occurred
public void OrderProcessingSucceeded(string orderType, TimeSpan duration)
{
processingDuration.Record(duration.TotalSeconds,
new KeyValuePair<string, object?>("myapp.order_type", orderType),
new KeyValuePair<string, object?>("outcome", "success")
);
}
public void OrderProcessingFailed(string orderType, Exception exception, TimeSpan duration)
{
processingDuration.Record(duration.TotalSeconds,
new KeyValuePair<string, object?>("myapp.order_type", orderType),
new KeyValuePair<string, object?>("outcome", "failure"),
new KeyValuePair<string, object?>("exception.type", exception.GetType().Name)
);
}
public void ConnectionOpened() => connectionsOpen.Add(1);
public void ConnectionClosed() => connectionsOpen.Add(-1);
}
// ❌ WRONG: Various naming anti-patterns
public void RecordOrderProcessingDuration(...) { } // ❌ Don't name after metric
public void RecordError(bool succeeded, Exception? ex) { } // ❌ Confusing signature
Rules (inspired by ASP.NET Core patterns):
OrderProcessingSucceeded, RetryAttempted, ConnectionFailedRecordXxx, IncrementXxxConnectionOpened(), ItemQueued()// ✅ CORRECT: Low-cardinality, predefined dimensions
public void OrderProcessingSucceeded(string orderType, TimeSpan duration)
{
processingDuration.Record(duration.TotalSeconds,
new KeyValuePair<string, object?>("myapp.order_type", orderType),
new KeyValuePair<string, object?>("myapp.region", region),
new KeyValuePair<string, object?>("outcome", "success")
);
}
// ❌ WRONG: High-cardinality dimensions (unbounded values cause cardinality explosion)
public void OrderFailed(string orderId, string exceptionMessage)
{
failureCount.Add(1,
new KeyValuePair<string, object?>("order_id", orderId), // ❌ Unbounded
new KeyValuePair<string, object?>("exception_message", exceptionMessage) // ❌ Unbounded
);
}
Rules:
myapp.region means same thing everywhereInstrumentation MUST be cheap by default. Follow these rules to minimize overhead:
// ✅ CORRECT: Guard with cheap checks
if (ActivitySource.HasListeners())
{
using var activity = ActivitySource.StartActivity("Operation");
// ... expensive work
}
// ✅ CORRECT: Use TagList (struct) for metrics
var tags = new TagList
{
{ "myapp.order_type", orderType },
{ "outcome", "success" }
};
counter.Add(1, tags);
// ✅ CORRECT: Timestamp math (no allocation)
var startTime = Stopwatch.GetTimestamp();
try
{
await ProcessAsync();
}
finally
{
var duration = Stopwatch.GetElapsedTime(startTime);
metrics.OrderProcessingSucceeded(orderType, duration);
}
// ❌ WRONG: Allocates Stopwatch object
var stopwatch = Stopwatch.StartNew(); // ❌ Allocates
// ❌ WRONG: IDisposable timing class (allocates per use)
using (new MetricScope(metrics, "ProcessOrder")) // ❌ BAD
{
ProcessOrder();
}
// ❌ WRONG: String interpolation allocates
activity?.SetTag("item", $"Processing {itemId}"); // ❌ Allocates
// ✅ CORRECT: Check IsAllDataRequested first
if (activity?.IsAllDataRequested == true)
{
activity.SetTag("item", $"Processing {itemId}");
}
// ❌ WRONG: LINQ allocates enumerators
activity?.SetTag("handlers", handlers.Select(h => h.Name).ToArray()); // ❌ Bad
// ✅ CORRECT: Manual construction or check first
if (activity?.IsAllDataRequested == true)
{
activity.SetTag("handlers", string.Join(",", handlers.Select(h => h.Name)));
}
Rules:
Stopwatch.StartNew() (use timestamp math)IDisposable wrappers as classesTagList (struct) over arrays/dictionaries[Test]
public async Task Should_create_processing_span_with_correct_parent()
{
// Arrange
using var parent = new Activity("Parent").Start();
// Act
await handler.Handle(item);
// Assert
var processingSpan = recordedActivities.Single(a => a.OperationName == "ProcessItem");
Assert.AreEqual(parent.Id, processingSpan.ParentId);
Assert.AreEqual("myapp.item_type", processingSpan.Tags.First().Key);
}
[Test]
public void Should_not_introduce_breaking_changes_to_span_names()
{
// Ensures string values in span names are under test
Assert.AreEqual("ProcessItem", MyFeature.SpanName);
}
Rules:
private static readonly ActivitySource ActivitySource = new("MyApp.MyComponent", "0.9.0");
private readonly Meter meter = new("MyApp.MyComponent", "0.8.0");