Table of Contents

Observability and Monitoring

Trellis emits OpenTelemetry Activity spans and ILogger entries from three ActivitySources — Trellis.Mediator, Trellis.Primitives, and Trellis.Core — wired through the canonical AddTrellis(...) composition root.

Patterns Index

Goal Use See
Wire mediator tracing + logging via the composition root services.AddTrellis(o => o.UseMediator()) Default registration
Subscribe an OTel TracerProvider to mediator spans tracing.AddSource("Trellis.Mediator") Tracing
Subscribe to value-object spans (validation/parsing) tracing.AddPrimitiveValueObjectInstrumentation() Tracing
Subscribe to ROP spans (deep Bind / Map debugging) tracing.AddResultsInstrumentation() Tracing
Read structured per-message logs with elapsed-ms outcomes Built in via LoggingBehavior<,> Logging
Allow Error.Detail into telemetry (non-PII environments only) o.UseMediator(t => t.IncludeErrorDetail = true) Redaction
Add business tags to the mediator span from a handler Activity.Current?.SetTag(...) Custom enrichment
Get HTTP / DB / runtime metrics Standard OTel instrumentation packages Metrics

Use this guide when

  • You are wiring observability into a Trellis composition root and want the same span/log surface as AddTrellisBehaviors() produces.
  • You need to know exactly which tags TracingBehavior<,> writes on success vs. failure, and what LoggingBehavior<,> emits.
  • You are deciding which ActivitySources to subscribe to — and which to leave off because they are noisy.
  • You need to confirm that Error.Detail is redacted by default and how to opt in.

Surface at a glance

Surface Type / API Emits Subscribed via
Mediator tracing TracingBehavior<TMessage, TResponse> (Trellis.Mediator) One Activity per mediator message; tags error.code, error.type; ActivityStatusCode.Ok / Error Registered by AddTrellisBehaviors(); subscribe with tracing.AddSource(TracingBehavior<,>.ActivitySourceName) (value: "Trellis.Mediator")
Mediator logging LoggingBehavior<TMessage, TResponse> (Trellis.Mediator) Information start, Information end (with elapsed ms), Warning on failure (with Error.Code) Registered by AddTrellisBehaviors(); consumed by any ILogger provider
Redaction TrellisMediatorTelemetryOptions (Trellis.Mediator) Controls whether Error.Detail flows into the activity status description and log message DI singleton, configured via o.UseMediator(t => ...) or AddTrellisBehaviors(t => ...)
Primitive value-object tracing Trellis.Primitives ActivitySource One Activity per TryCreate / Parse on a Required*<TSelf> value object tracing.AddPrimitiveValueObjectInstrumentation()
Result / ROP tracing Trellis.Core ActivitySource (RopTrace.ActivitySourceName) Spans for individual Result operations — verbose; intended for diagnostics tracing.AddResultsInstrumentation()
Composition root TrellisServiceBuilder.UseMediator(Action<TrellisMediatorTelemetryOptions>?) Registers the five canonical behaviors and the telemetry options services.AddTrellis(o => o.UseMediator(...))

Full signatures: trellis-api-servicedefaults.md, trellis-api-mediator.md, trellis-api-core.md.

Installation

dotnet add package Trellis.ServiceDefaults
dotnet add package OpenTelemetry.Extensions.Hosting
dotnet add package OpenTelemetry.Exporter.OpenTelemetryProtocol
dotnet add package OpenTelemetry.Instrumentation.AspNetCore
dotnet add package OpenTelemetry.Instrumentation.Http

Trellis.Core already references the OpenTelemetry SDK so AddResultsInstrumentation() and AddPrimitiveValueObjectInstrumentation() work without extra packages. The exporter and ASP.NET Core / HTTP instrumentation packages above are standard OTel — Trellis does not wrap them.

Quick start

Register Trellis (which wires TracingBehavior<,> and LoggingBehavior<,> into the mediator pipeline) and subscribe an OpenTelemetry TracerProvider to the mediator activity source.

using Mediator;
using OpenTelemetry.Trace;
using Trellis;
using Trellis.Mediator;
using Trellis.ServiceDefaults;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddMediator();
builder.Services.AddTrellis(options => options
    .UseAsp()
    .UseMediator());

builder.Services.AddOpenTelemetry()
    .WithTracing(tracing => tracing
        .AddAspNetCoreInstrumentation()
        .AddHttpClientInstrumentation()
        .AddSource(TracingBehavior<IMessage, IResult>.ActivitySourceName)
        .AddPrimitiveValueObjectInstrumentation()
        .AddOtlpExporter());

var app = builder.Build();
app.Run();

public sealed record CreateOrder(string CustomerId, decimal Total) : ICommand<Result<Unit>>;

public sealed class CreateOrderHandler : ICommandHandler<CreateOrder, Result<Unit>>
{
    public ValueTask<Result<Unit>> Handle(CreateOrder command, CancellationToken cancellationToken)
        => ValueTask.FromResult(Result.Ok());
}

Every dispatch of CreateOrder now produces:

  • one Activity named "CreateOrder" under source "Trellis.Mediator", and
  • two structured log entries (Handling CreateOrder, Handled CreateOrder in N.NNms).

Default registration

AddTrellis(o => o.UseMediator()) is the canonical entry point. It calls AddTrellisBehaviors() and is idempotentUseFluentValidation, UseResourceAuthorization, and UseEntityFrameworkUnitOfWork<TContext> all imply UseMediator, so repeating the call cannot duplicate registrations.

Wiring step What it adds
o.UseMediator() Registers ExceptionBehavior<,>, TracingBehavior<,>, LoggingBehavior<,>, AuthorizationBehavior<,>, ValidationBehavior<,> and a default TrellisMediatorTelemetryOptions (Detail redacted).
o.UseMediator(t => t.IncludeErrorDetail = true) Same, but replaces the options singleton so Error.Detail flows into telemetry.

Pipeline order (outermost → innermost) is: Exception → Tracing → Logging → Authorization → (ResourceAuthorization, opt-in) → Validation → (Transactional, opt-in via UseEntityFrameworkUnitOfWork<TContext>). Every inner behavior runs inside the Trellis.Mediator activity, so its logs and child spans inherit the same trace and span correlation IDs. See trellis-api-mediator.md → Canonical pipeline order.

Tracing

Trellis ships three independent ActivitySources. Subscribe to each on its own merits.

Source Constant Volume When to subscribe
"Trellis.Mediator" TracingBehavior<,>.ActivitySourceName One span per mediator message Always — this is the primary failure-localisation surface.
"Trellis.Primitives" (internal — use AddPrimitiveValueObjectInstrumentation()) One span per TryCreate / Parse on a Required*<TSelf> value object When you need to see why input validation rejected a request at the edge.
"Trellis.Core" RopTrace.ActivitySourceName (= "Trellis.Core") One span per individual Result operation (Bind, Map, Tap, …) Only for break-glass diagnostics — high cardinality, high volume.

TracingBehavior<,> writes the following on every dispatched message:

Outcome Activity status Tags written
Handler returns success ActivityStatusCode.Ok (none)
Handler returns Result.Fail(error) ActivityStatusCode.Error error.code = error.Code; error.type = stable error class name (e.g. Error.NotFound); StatusDescription left empty unless IncludeErrorDetail = true
Handler throws ActivityStatusCode.Error error.type = exception type name; the exception is rethrown; the exception message is not copied into telemetry
using Mediator;
using OpenTelemetry.Trace;
using Trellis.Mediator;

builder.Services.AddOpenTelemetry()
    .WithTracing(tracing => tracing
        .AddAspNetCoreInstrumentation()
        .AddHttpClientInstrumentation()
        .AddSource(TracingBehavior<IMessage, IResult>.ActivitySourceName)
        .AddPrimitiveValueObjectInstrumentation()
        .AddOtlpExporter());

To enable ROP forensics during an investigation, add .AddResultsInstrumentation() on top — and remove it again when the investigation is over.

builder.Services.AddOpenTelemetry()
    .WithTracing(tracing => tracing
        .AddSource(TracingBehavior<IMessage, IResult>.ActivitySourceName)
        .AddPrimitiveValueObjectInstrumentation()
        .AddResultsInstrumentation()
        .AddConsoleExporter());
Warning

AddResultsInstrumentation() is a debugging tool. It traces every Bind, Map, and Tap and can flood your collector. Keep it off in production unless you have an active incident.

Logging

LoggingBehavior<,> is registered alongside TracingBehavior<,> and uses source-generated LoggerMessage methods — no allocation on the hot path. It emits exactly three messages per dispatch:

Stage Level Template
Entry Information Handling {MessageName}
Success exit Information Handled {MessageName} in {ElapsedMs:0.00}ms
Failure exit Warning Handled {MessageName} in {ElapsedMs:0.00}ms — Failed: {ErrorSummary}

{MessageName} is typeof(TMessage).Name. {ErrorSummary} is error.Code by default; it becomes error.GetDisplayMessage() (which includes Error.Detail) only when IncludeErrorDetail = true. Because the log entries are written inside the mediator activity, every entry already carries the trace/span IDs propagated by your logging provider's OTel integration.

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

Redaction

TrellisMediatorTelemetryOptions.IncludeErrorDetail is the single redaction switch. It defaults to false. Both TracingBehavior<,> and LoggingBehavior<,> consume it.

Setting error.Code error.type Error.Detail
IncludeErrorDetail = false (default) Emitted Emitted Redacted from Activity.StatusDescription and from log messages
IncludeErrorDetail = true Emitted Emitted Included in Activity.StatusDescription and the failure log message

Configure via the composition root:

services.AddTrellis(options => options
    .UseAsp()
    .UseMediator(telemetry => telemetry.IncludeErrorDetail = builder.Environment.IsDevelopment()));

Or directly when not using AddTrellis(...):

services.AddTrellisBehaviors(telemetry => telemetry.IncludeErrorDetail = false);
Warning

Error.Detail is the only Trellis-emitted field that may carry user input or domain payloads (an order id, an email address, a free-text validation message). Error.Code and the stable error type name are operator-defined identifiers and remain PII-free regardless of this setting.

Metrics

Trellis does not register an OpenTelemetry Meter and emits no framework counters or histograms. Use the standard OpenTelemetry instrumentation packages for runtime, ASP.NET Core, and HTTP-client metrics:

builder.Services.AddOpenTelemetry()
    .WithMetrics(metrics => metrics
        .AddAspNetCoreInstrumentation()
        .AddHttpClientInstrumentation()
        .AddRuntimeInstrumentation()
        .AddOtlpExporter());

For per-message latency dashboards, derive metrics from the mediator activity (Trellis.Mediator) or from the Handled {MessageName} in {ElapsedMs:0.00}ms log entries — both already carry the elapsed time.

Custom enrichment

TracingBehavior<,> opens its activity before the inner behaviors run, so any code inside the handler (or in inner behaviors) can tag the same activity through Activity.Current.

using System.Diagnostics;
using Mediator;
using Trellis;

public sealed record SubmitInvoice(string CustomerId, decimal Amount) : ICommand<Result<Unit>>;

public sealed class SubmitInvoiceHandler : ICommandHandler<SubmitInvoice, Result<Unit>>
{
    public ValueTask<Result<Unit>> Handle(SubmitInvoice command, CancellationToken cancellationToken)
    {
        Activity.Current?.SetTag("invoice.customer_id", command.CustomerId);
        Activity.Current?.SetTag("invoice.amount", command.Amount);

        return ValueTask.FromResult(Result.Ok());
    }
}

For broader cross-cutting enrichment (every span, not just mediator messages), use a standard OpenTelemetry BaseProcessor<Activity> registered on the TracerProviderBuilder — Trellis does not provide a wrapper because the OTel SDK already does.

Composition

The mediator activity is the natural parent for any spans your business code opens around the same operation. Open a child ActivitySource from your application, and it will be correlated with the Trellis.Mediator activity (and any ASP.NET Core request span above it) automatically.

using System.Diagnostics;
using Mediator;
using Trellis;

public sealed record SettleBatch(string BatchId) : ICommand<Result<Unit>>;

public sealed class SettleBatchHandler : ICommandHandler<SettleBatch, Result<Unit>>
{
    private static readonly ActivitySource Source = new("Acme.Billing");

    public ValueTask<Result<Unit>> Handle(SettleBatch command, CancellationToken cancellationToken)
    {
        using var activity = Source.StartActivity("Billing.SettleBatch");
        activity?.SetTag("batch.id", command.BatchId);

        return ValueTask.FromResult(Result.Ok());
    }
}

Subscribe to the new source the same way:

tracing.AddSource("Acme.Billing");

Practical guidance

  • Subscribe to Trellis.Mediator always. It is one span per message and is the cheapest lever for failure localisation.
  • Add AddPrimitiveValueObjectInstrumentation() early. Validation traces are the clearest signal for "why is this request being rejected at the edge?".
  • Treat AddResultsInstrumentation() as break-glass. Turn it on for an incident, turn it off afterwards.
  • Keep IncludeErrorDetail = false in production. Code + type are sufficient for dashboards and alerts; flip it on per-environment only when you have audited every Error.Detail write site.
  • Sample at the collector or via OTel. Trellis does not sample. Set SetSampler(new TraceIdRatioBasedSampler(0.1)) on the TracerProvider if you need to throttle.
  • Use error.code for grouping. It is stable across releases (e.g. validation.error, not.found.error, forbidden.error) and far better than free-form messages.

Cross-references