Skip to main content

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>();
});
tip

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).

InstrumentTypeDescription
mediator.events.dispatchedCounterEvents dispatched (event.type, success)
mediator.events.dispatch_failuresCounterEvent dispatch failures (event.type)
mediator.events.dispatch.durationHistogram (ms)Dispatch duration (event.type)
mediator.outbox.events.processedCounterEvents processed from the outbox (event.type, success)
mediator.outbox.events.dead_letteredCounterEvents moved to the dead-letter queue (event.type)
mediator.outbox.queue_depthGaugePending events in the outbox
mediator.outbox.processing_lagGauge (seconds)Age of the oldest pending event
mediator.outbox.retrying_countGaugeEvents that failed at least once and await retry
mediator.outbox.dead_letter_countGaugeEvents 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),
});
ThresholdDefaultEffect when reached
DegradedPendingThreshold1000Health status → Degraded
CriticalPendingThreshold5000Health status → Unhealthy
DegradedDeadLetterThreshold10Health status → Degraded
CriticalDeadLetterThreshold50Health status → Unhealthy
DegradedLagThreshold5 minHealth status → Degraded
CriticalLagThreshold15 minHealth status → Unhealthy

Expose the health endpoint

app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
Predicate = hc => hc.Tags.Contains("ready")
});

See also