Lean .NET observability on OpenTelemetry. IWitness<T> gives each call site one place for logs, metrics, and traces.
WitnessSharp keeps the underlying .NET types visible. You still work with ILogger<T>, Meter, ActivitySource, configuration binding, and OpenTelemetry exporters. The package just gives them a clean shape and a small bootstrap API.
Supports net8.0 and net10.0.
// Program.cs
builder.Services.AddWitness(builder.Configuration.GetSection("Witness"))
.WithStandardInstrumentations()
.WithOtlpExporter();
// In your service
public sealed class OrderService(IWitness<OrderService> witness)
{
public void PlaceOrder(int orderId)
{
using var action = witness.StartAction("PlaceOrder");
action.SetTag("order.id", orderId);
// business logic
}
}AddWitness() binds WitnessOptions from the "Witness" section. Registration without any .With*() calls is valid if you only want the core primitives.
IWitness<T> is the main injectable — it bundles ILogger<T>, Meter, and ActivitySource with no new abstractions over them. Most classes only need IWitness<T>; for witnesses created at runtime, inject IWitnessFactory and call Create<T>().
WitnessedAction wraps an Activity. Start one with witness.StartAction("Name"), attach tags or events, and dispose when the operation ends. Outcomes default to success; call Failed(Exception), Failed(string), or Cancelled() as needed. Finish() stops recording early without disposing the wrapper.
When started from a typed IWitness<T>, the action also implements IWitness<T>, so extension methods resolve directly:
public async Task<DashboardSummary> RetrieveSummaryAsync()
{
using var action = witness.StartAction(nameof(RetrieveSummaryAsync));
try
{
var summary = await _controller.RetrieveSummaryAsync();
action.LogDashboardSummaryRetrieved(); // same extension you'd call on the witness
return summary;
}
catch (Exception exception)
{
action.Failed(exception);
throw;
}
}Use var (not an explicit WitnessedAction type) so the action keeps its IWitness<T> facet and the typed extension methods resolve.
WitnessSharp leans toward extension methods on IWitness<T> for recurring log messages. That keeps message templates in one place and keeps call sites small.
public static class OrderServiceWitnessExtensions
{
public static void LogOrderPlaced(this IWitness<OrderService> witness, int orderId) =>
witness.Logger.LogInformation("Order {OrderId} placed", orderId);
}The optional analyzer package spots these patterns and nudges you toward LoggerMessage where it pays off.
dotnet add package WitnessSharp
dotnet add package WitnessSharp.AzureMonitor # optional
dotnet add package WitnessSharp.Analyzers # optional
dotnet add package WitnessSharp.Testing # test projectsYou can configure WitnessSharp with either overload:
builder.Services.AddWitness(builder.Configuration.GetSection("Witness"));
// or
builder.Services.AddWitness(options =>
{
options.ServiceName = "orders-api";
});{
"Witness": {
"ServiceName": "orders-api",
"ServiceNamespace": "Contoso.Commerce",
"ServiceVersion": "1.3.0",
"ServiceInstanceId": "orders-api-01",
"DeploymentEnvironment": "Production",
"AdditionalResourceAttributes": {
"service.owner": "checkout",
"cloud.region": "westeurope",
"deployment.ring": "blue"
}
}
}| Property | Description | Default |
|---|---|---|
ServiceName |
Sets service.name. This is the main identity of your service. |
Empty string. Set this in real apps. |
ServiceNamespace |
Sets service.namespace. Useful when several services share the same base name. |
null |
ServiceVersion |
Sets service.version. |
null |
ServiceInstanceId |
Sets service.instance.id. |
Environment.MachineName |
DeploymentEnvironment |
Sets deployment.environment. |
DOTNET_ENVIRONMENT, then ASPNETCORE_ENVIRONMENT |
AdditionalResourceAttributes |
Adds any extra resource attributes you want on logs, metrics, and traces. | Empty dictionary |
Registration by itself is valid. Add builder methods when you want instrumentations or exporters.
| Method | What it does | Notes |
|---|---|---|
WithStandardInstrumentations() |
Adds ASP.NET Core and HttpClient tracing instrumentation. |
Good default for web apps. |
WithAspNetCoreInstrumentation(...) |
Adds ASP.NET Core tracing instrumentation. | Use the overload when you need request filtering or enrichment. |
WithHttpClientInstrumentation(...) |
Adds HttpClient tracing instrumentation. |
Useful for outbound calls from services or APIs. |
WithOtlpExporter(...) |
Adds OTLP exporters for traces, metrics, and logs. | Good fit for OpenTelemetry Collector, Jaeger, Tempo, and similar backends. |
WithConsoleExporter() |
Adds console exporters for traces, metrics, and logs. | Handy for local debugging. |
WithAzureMonitor(...) |
Adds Azure Monitor exporters for traces, metrics, and logs. | Comes from WitnessSharp.AzureMonitor. |
ClearLoggingProviders() |
Clears existing Microsoft.Extensions.Logging providers before OpenTelemetry logging is added. |
Opt in only if you want OTel to be the only logging provider. |
Use the escape hatches when the built-in convenience methods are not enough:
| Method | Use it for |
|---|---|
ConfigureTracing(Action<TracerProviderBuilder>) |
Custom sources, filters, processors, samplers, or exporter pipelines |
ConfigureMetrics(Action<MeterProviderBuilder>) |
Custom meters, views, readers, or exporters |
ConfigureLogging(Action<OpenTelemetryLoggerOptions>) |
OpenTelemetry logging options and exporters |
If you configure an instrumentation manually through ConfigureTracing, skip the matching convenience method to avoid registering the same instrumentation twice.
WitnessSharp does not ship hard-coded health-check or SQL filters. Those choices depend on your app. Use the escape hatches and keep the policy in your service code.
Filter out health-check spans
Use ConfigureTracing() when you need to own the ASP.NET Core instrumentation options.
builder.Services.AddWitness(builder.Configuration.GetSection("Witness"))
.ConfigureTracing(tracing =>
{
tracing.AddAspNetCoreInstrumentation(options =>
{
options.Filter = httpContext =>
!httpContext.Request.Path.StartsWithSegments("/health") &&
!httpContext.Request.Path.StartsWithSegments("/ready");
});
tracing.AddHttpClientInstrumentation();
})
.WithOtlpExporter();This pattern is a good fit when WithStandardInstrumentations() is almost right, but you need a request filter.
Filter fast SQL spans with a custom processor
Duration-based SQL filtering is app-specific, so WitnessSharp leaves it to your tracing pipeline. This example keeps SQL spans that run for at least 100 ms and exports everything else as usual.
This recipe assumes you have also installed the SQL client instrumentation package from the OpenTelemetry ecosystem.
using System.Diagnostics;
using System.Linq;
using OpenTelemetry;
using OpenTelemetry.Exporter;
using OpenTelemetry.Trace;
public sealed class MinimumDurationSqlProcessor : BaseProcessor<Activity>
{
private readonly BatchActivityExportProcessor _inner;
private readonly TimeSpan _minimumDuration;
public MinimumDurationSqlProcessor(BaseExporter<Activity> exporter, TimeSpan minimumDuration)
{
_inner = new BatchActivityExportProcessor(exporter);
_minimumDuration = minimumDuration;
}
public override void OnEnd(Activity data)
{
var isSqlSpan = data.Kind == ActivityKind.Client &&
data.Tags.Any(tag => tag.Key == "db.system");
if (!isSqlSpan || data.Duration >= _minimumDuration)
{
_inner.OnEnd(data);
}
}
protected override bool OnForceFlush(int timeoutMilliseconds) =>
_inner.ForceFlush(timeoutMilliseconds);
protected override bool OnShutdown(int timeoutMilliseconds) =>
_inner.Shutdown(timeoutMilliseconds);
protected override void Dispose(bool disposing)
{
if (disposing)
{
_inner.Dispose();
}
base.Dispose(disposing);
}
}builder.Services.AddWitness(builder.Configuration.GetSection("Witness"))
.ConfigureTracing(tracing =>
{
tracing.AddSqlClientInstrumentation();
tracing.AddProcessor(new MinimumDurationSqlProcessor(
new OtlpTraceExporter(new OtlpExporterOptions
{
Endpoint = new Uri("http://localhost:4317")
}),
TimeSpan.FromMilliseconds(100)));
})
.ConfigureMetrics(metrics => metrics.AddOtlpExporter())
.ConfigureLogging(logging => logging.AddOtlpExporter());Do not combine this trace setup with .WithOtlpExporter(), or you will export traces twice.
Send all three signals to Azure Monitor
Install WitnessSharp.AzureMonitor, then add the Azure Monitor exporters with one call.
builder.Services.AddWitness(builder.Configuration.GetSection("Witness"))
.WithStandardInstrumentations()
.WithAzureMonitor(options =>
{
options.ConnectionString = builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"];
});If your environment already sets APPLICATIONINSIGHTS_CONNECTION_STRING, the parameterless .WithAzureMonitor() overload also works.
See the Azure Monitor OpenTelemetry exporter docs for Azure-specific options and guidance.
Add custom resource attributes
AdditionalResourceAttributes adds shared metadata to logs, metrics, and traces. To set it in code:
builder.Services.AddWitness(options =>
{
options.ServiceName = "orders-api";
options.AdditionalResourceAttributes["service.owner"] = "checkout";
options.AdditionalResourceAttributes["cloud.region"] = "westeurope";
options.AdditionalResourceAttributes["deployment.ring"] = "blue";
});WitnessSharp.Testing gives you TestWitness<T>, an in-memory test double that records logged messages, metrics, and activities. It includes AssertLogged(...), AssertMetricRecorded(...), and AssertActivityStarted(...) assertion helpers.
Example:
using Microsoft.Extensions.Logging;
using WitnessSharp.Testing;
public class OrderServiceTests
{
[Fact]
public void PlaceOrder_emits_expected_telemetry()
{
using var witness = new TestWitness<OrderService>();
var counter = witness.Meter.CreateCounter<int>("orders");
witness.Logger.LogInformation("Placed order 42");
counter.Add(1);
using (witness.StartAction("PlaceOrder"))
{
}
witness.AssertLogged(LogLevel.Information, "Placed order");
witness.AssertMetricRecorded("orders");
witness.AssertActivityStarted("PlaceOrder");
}
}WitnessSharp.Analyzers is an optional Roslyn analyzer package. WS0001 flags witness.Logger.Log*(...) calls inside IWitness<T> extension methods and provides a code fix that rewrites them to [LoggerMessage] for allocation-free structured logging. See the WS0001 rule documentation for the full fix pattern.
dotnet_diagnostic.WS0001.severity = warningFor background on source-generated logging, see the official LoggerMessage docs.
WitnessSharp is designed to stay friendly to trimming and native AOT. The core package uses standard .NET and OpenTelemetry APIs. Your final AOT story depends on the instrumentations and exporters you enable — when publishing with PublishAot=true, watch for warnings from upstream packages.
| Package | Purpose |
|---|---|
WitnessSharp |
Core primitives, DI registration, IWitness<T>, WitnessedAction, options, and fluent builder extensions |
WitnessSharp.AzureMonitor |
Azure Monitor exporter wiring via .WithAzureMonitor() |
WitnessSharp.Analyzers |
Roslyn analyzer package with WS0001 |
WitnessSharp.Testing |
TestWitness<T> and assertion helpers for test projects |
Contributions are welcome. Build with dotnet build WitnessSharp.slnx, test with dotnet test WitnessSharp.slnx, then open a pull request. If a CONTRIBUTING.md appears, follow that file first.
MIT. See LICENSE.