Observability & Monitoring
Level: Intermediate 📘 | Time: 15-20 min | Prerequisites: Basics
Observability matters most when something fails between layers: an HTTP call returns a conflict, a mediator command fails authorization, or a value object rejects input. Trellis exposes tracing hooks for exactly those boundaries.
Installation
dotnet add package OpenTelemetry
dotnet add package OpenTelemetry.Extensions.Hosting
dotnet add package OpenTelemetry.Exporter.OpenTelemetryProtocol
The short version
Trellis can emit spans from three useful places:
| Source | Activity source name | Best use |
|---|---|---|
| Mediator pipeline | Trellis.Mediator |
command/query tracing |
| Primitive value objects | Trellis.Primitives |
validation and parsing boundaries |
| Result operations | Trellis.Results |
deep ROP debugging |
flowchart LR
A[ASP.NET Core request] --> B[Trellis.Mediator]
B --> C[Your ActivitySource]
C --> D[Trellis.Primitives]
C --> E[Trellis.Http / other dependencies]
D --> F[OpenTelemetry exporter]
E --> F
B --> F
Start with the low-noise option
For most apps, the best first step is:
- add the mediator source
- add primitive value object instrumentation
- keep full result instrumentation off until you need deep debugging
using Trellis;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOpenTelemetry()
.WithTracing(tracing => tracing
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddSource("Trellis.Mediator")
.AddPrimitiveValueObjectInstrumentation()
.AddOtlpExporter());
Tip
AddPrimitiveValueObjectInstrumentation() is often the best default because it shows where input validation succeeds or fails without tracing every Bind, Map, and Tap.
When to enable AddResultsInstrumentation()
AddResultsInstrumentation() traces Railway-Oriented Programming operations from Trellis.Results.
That is powerful, but noisy.
using Trellis;
builder.Services.AddOpenTelemetry()
.WithTracing(tracing => tracing
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddSource("Trellis.Mediator")
.AddPrimitiveValueObjectInstrumentation()
.AddResultsInstrumentation()
.AddConsoleExporter());
Use it when you need to answer questions like:
- which
Bindstep failed? - where did a result switch from success to failure?
- which branch in a long workflow produced the error?
Warning
AddResultsInstrumentation() is a debugging tool, not a default production recommendation. It can create a lot of spans.
What mediator tracing gives you
If you use AddTrellisBehaviors(), TracingBehavior creates an activity for every mediator message.
On success:
- status →
Ok
On failure:
- status →
Error - tag
error.type - tag
error.code
That makes failed command/query execution easy to spot in any OpenTelemetry backend.
Manual business instrumentation still matters
Framework spans tell you where something failed. Your own spans explain what the business operation was doing.
using System.Diagnostics;
using System.Net.Http.Json;
using System.Text.Json.Serialization;
using Trellis;
using Trellis.Http;
[JsonSerializable(typeof(CreateOrderRequest))]
[JsonSerializable(typeof(OrderReceiptDto))]
internal partial class OrdersJsonContext : JsonSerializerContext
{
}
public sealed record CreateOrderRequest(string CustomerId, decimal Amount);
public sealed record OrderReceiptDto(string OrderId, decimal Amount);
public sealed class CheckoutClient(HttpClient httpClient)
{
private static readonly ActivitySource ActivitySource = new("Acme.Checkout");
public async Task<Result<OrderReceiptDto>> SubmitAsync(
CreateOrderRequest request,
CancellationToken cancellationToken)
{
using var activity = ActivitySource.StartActivity("Checkout.Submit");
activity?.SetTag("order.customer_id", request.CustomerId);
activity?.SetTag("order.amount", request.Amount);
var result = await httpClient.PostAsJsonAsync(
"orders",
request,
OrdersJsonContext.Default.CreateOrderRequest,
cancellationToken)
.HandleConflictAsync(Error.Conflict("An order with the same data already exists."))
.ReadResultFromJsonAsync(OrdersJsonContext.Default.OrderReceiptDto, cancellationToken);
if (result.IsFailure)
{
activity?.SetStatus(ActivityStatusCode.Error, result.Error.Detail);
activity?.SetTag("error.code", result.Error.Code);
activity?.SetTag("error.type", result.Error.GetType().Name);
}
else
{
activity?.SetStatus(ActivityStatusCode.Ok);
}
return result;
}
}
Why error codes matter in traces
Trellis errors carry machine-readable codes such as:
validation.errornot.found.errorforbidden.error
Those codes are better than free-form log messages when you want dashboards, alerts, or grouped failure analysis.
Suggested production setup
If you are sending data to a real collector, add sampling and keep the sources intentional.
using OpenTelemetry.Trace;
using Trellis;
builder.Services.AddOpenTelemetry()
.WithTracing(tracing => tracing
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddSource("Trellis.Mediator")
.AddPrimitiveValueObjectInstrumentation()
.SetSampler(new TraceIdRatioBasedSampler(0.1))
.AddOtlpExporter());
Practical guidance
Prefer signal over volume
If every Result operation is traced, the interesting span can get buried. Start small.
Use business spans for expensive workflows
Examples:
- checkout
- invoice generation
- approval workflows
- cross-service orchestration
Use primitive traces to understand bad inputs
If you want to know why requests are failing validation at the edge, Trellis.Primitives is usually the clearest signal.
Use result tracing only when needed
Turn on AddResultsInstrumentation() when debugging a complicated pipeline, then turn it back down.