Observability
Synapse exposes structured logging, distributed tracing, metrics, and health checks out of the box. All hooks integrate with the standard .NET observability stack (Microsoft.Extensions.Logging, System.Diagnostics.ActivitySource, System.Diagnostics.Metrics, and IHealthCheck).
Structured logging with built-in behaviors
SimpleLoggingBehavior<TRequest, TResponse> / SimpleLoggingEventBehavior<TEvent>
Logs the name of each request or event and the elapsed time after the handler completes. The behaviors are typed; register a closed pair per handler (SimpleLoggingBehavior<TRequest> for void requests):
services.AddSynapse(cfg =>
{
cfg.RegisterRequestPipelineBehavior<
SimpleLoggingBehavior<CreateTaskCommand, Guid>, CreateTaskCommand, Guid>();
cfg.RegisterEventPipelineBehavior<
SimpleLoggingEventBehavior<TaskCreatedEvent>, TaskCreatedEvent>();
});
To apply logging across every handler without naming each pair, make an open-generic behavior and mark it [PipelineBehavior] so the source generator emits the closed registrations for you. See Pipeline Behaviors.
Sample output:
info: Synapse[0] Handling CreateTaskCommand
info: Synapse[0] CreateTaskCommand completed in 4ms
LoggingEnrichmentBehavior<TRequest, TResponse>
Enriches the ILogger scope with CorrelationId and all context metadata so every log entry emitted during the request automatically includes them — no manual BeginScope calls needed:
cfg.RegisterRequestPipelineBehavior<
LoggingEnrichmentBehavior<CreateTaskCommand, Guid>,
CreateTaskCommand,
Guid>();
Every ILogger call inside the handler or any downstream service will include:
{
"CorrelationId": "019550a7-0000-7000-0000-000000000000",
"OccuredAt": "2026-04-08T10:00:00Z"
}
Distributed tracing
Synapse creates an ActivitySource named Unambitious.Synapse. Any pipeline behavior or handler can start a child span using it:
using System.Diagnostics;
using UnambitiousFx.Synapse.Observability;
[PipelineBehavior]
public sealed class TracingBehavior<TRequest, TResponse>
: IRequestPipelineBehavior<TRequest, TResponse>
where TRequest : IRequest<TResponse>
where TResponse : notnull
{
public async ValueTask<Result<TResponse>> HandleAsync(
TRequest request,
RequestHandlerDelegate<TRequest, TResponse> next,
CancellationToken cancellationToken = default)
{
using var activity = SynapseActivitySource.Source.StartActivity(typeof(TRequest).Name);
var result = await next(request, cancellationToken);
activity?.SetStatus(result.IsSuccess ? ActivityStatusCode.Ok : ActivityStatusCode.Error);
return result;
}
}
[PipelineBehavior] lets the source generator close this open-generic behavior over every matching handler. See Pipeline Behaviors.
To export traces to an OpenTelemetry collector, register the activity source when configuring OpenTelemetry:
builder.Services.AddOpenTelemetry()
.WithTracing(tracing => tracing
.AddSource("Unambitious.Synapse")
.AddOtlpExporter());
Metrics
ISynapseMetrics is backed by System.Diagnostics.Metrics and works with any OpenTelemetry-compatible metrics exporter.
All instruments are published on the Unambitious.Synapse meter. Counters/histograms carry an event.type tag (and a success tag where applicable).
| Instrument | Type | Description |
|---|---|---|
mediator.events.dispatched | Counter | Events dispatched (event.type, success) |
mediator.events.dispatch_failures | Counter | Event dispatch failures (event.type) |
mediator.events.dispatch.duration | Histogram (ms) | Dispatch duration (event.type) |
mediator.outbox.events.processed | Counter | Events processed from the outbox (event.type, success) |
mediator.outbox.events.dead_lettered | Counter | Events moved to the dead-letter queue (event.type) |
mediator.outbox.queue_depth | Gauge | Pending events in the outbox |
mediator.outbox.processing_lag | Gauge (seconds) | Age of the oldest pending event |
mediator.outbox.retrying_count | Gauge | Events that failed at least once and await retry |
mediator.outbox.dead_letter_count | Gauge | Events that exhausted retries |
Export to Prometheus + OpenTelemetry:
builder.Services.AddOpenTelemetry()
.WithMetrics(metrics => metrics
.AddMeter("Unambitious.Synapse")
.AddPrometheusExporter());
Replace the default implementation to customise how the recorded operations are emitted. ISynapseMetrics exposes four record methods — RecordEventDispatched, RecordDispatchLatency, RecordOutboxEventProcessed, and RecordOutboxDeadLettered (the outbox gauges are observed internally by SynapseMetrics):
builder.Services.AddSingleton<ISynapseMetrics, MyMetrics>();
Outbox health check
OutboxHealthCheck implements IHealthCheck and reports the health of the event outbox. It monitors pending event count, dead-lettered event count, and queue lag (the age of the oldest pending event).
Register
builder.Services.AddHealthChecks()
.AddCheck<OutboxHealthCheck>("outbox", tags: ["ready"]);
Configure thresholds
OutboxHealthCheck takes its OutboxHealthCheckOptions directly through its constructor (not via IOptions<T>), so register a configured instance as a singleton — AddCheck<OutboxHealthCheck> then resolves it through the optional constructor parameter:
builder.Services.AddSingleton(new OutboxHealthCheckOptions
{
DegradedPendingThreshold = 50,
CriticalPendingThreshold = 200,
DegradedDeadLetterThreshold = 5,
CriticalDeadLetterThreshold = 20,
DegradedLagThreshold = TimeSpan.FromMinutes(5),
CriticalLagThreshold = TimeSpan.FromMinutes(15),
});
| Threshold | Default | Effect when reached |
|---|---|---|
DegradedPendingThreshold | 1000 | Health status → Degraded |
CriticalPendingThreshold | 5000 | Health status → Unhealthy |
DegradedDeadLetterThreshold | 10 | Health status → Degraded |
CriticalDeadLetterThreshold | 50 | Health status → Unhealthy |
DegradedLagThreshold | 5 min | Health status → Degraded |
CriticalLagThreshold | 15 min | Health status → Unhealthy |
Expose the health endpoint
app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
Predicate = hc => hc.Tags.Contains("ready")
});
See also
- Pipeline Behaviors —
SimpleLoggingBehaviorandLoggingEnrichmentBehaviorregistration. - Outbox Pattern — outbox configuration and storage replacement.