Skip to content

Observability

Deepstaging instruments every generated effect method with OpenTelemetry tracing, metrics, and structured logging. Configuration is either code-driven (per-runtime) or config-driven (global toggle).

Set one value and every runtime in your application gets full distributed tracing with OTLP export:

appsettings.json
{
  "Deepstaging": {
    "Telemetry": {
      "Enabled": true
    }
  }
}

That's it. When running under Aspire, the OTLP endpoint is set automatically via the OTEL_EXPORTER_OTLP_ENDPOINT environment variable — traces appear in the Aspire dashboard with zero additional configuration.

Environment variables work too

Deepstaging__Telemetry__Enabled=true enables telemetry via standard .NET configuration binding, useful for Docker/Kubernetes deployments.

What Happens

When Deepstaging:Telemetry:Enabled is true, the generated bootstrapper for every runtime automatically:

  1. Registers ActivitySources — one per module (e.g., MyApp.OrderDispatch, MyApp.OrderStore)
  2. Adds Microsoft.AspNetCore source — captures HTTP request spans
  3. Configures the OTLP exporter — exports traces to whatever endpoint OTEL_EXPORTER_OTLP_ENDPOINT points at
  4. Enables metrics (if the runtime has instrumented modules) — effect counters and duration histograms

The OTLP exporter is configured exactly once across all runtimes (via an internal guard), so multiple AddXxxRuntime() calls don't produce duplicate exports.

Code-Driven (Per-Runtime)

For fine-grained control, enable observability per-runtime in the configure callback:

builder.AddCatalogRuntime(rt =>
{
    rt.EnableTracing();   // OpenTelemetry tracing
    rt.EnableMetrics();   // Effect operation metrics
    rt.EnableLogging();   // Structured logging on each effect
});

Code-driven and config-driven approaches compose — either path activates telemetry. A runtime with EnableTracing() called in code is traced regardless of the config value, and a global Enabled: true in config enables tracing on runtimes that didn't explicitly call EnableTracing().

Configuration Reference

The DeepstagingTelemetryOptions class is a [ConfigSection("Deepstaging:Telemetry")]:

Property Type Default Description
Enabled bool false Enables tracing and metrics across all runtimes
OtlpEndpoint string? null Overrides OTEL_EXPORTER_OTLP_ENDPOINT for the OTLP exporter

The OtlpEndpoint property is marked [Secret] and should be stored in user-secrets for production deployments.


Tracing

ActivitySources

Each generated module creates a static ActivitySource named {Namespace}.{ModuleName}:

MyApp.OrderDispatch          — dispatch commands/queries
MyApp.OrderStore             — data store operations
MyApp.Handlers.OrderEventHandlers  — integration event handlers

When tracing is enabled, the bootstrapper registers all sources with AddOpenTelemetry().WithTracing(...). Additionally, the Microsoft.AspNetCore source is registered to capture inbound HTTP request spans.

Effect Instrumentation

Every generated effect method is wrapped with .WithActivity(), which creates a span containing:

Tag Description
otel.status_code Ok on success, Error on failure
peer.service Destination service (for APM service maps)
correlation.id From CorrelationContext, if available
user.id From CorrelationContext, if available
tenant.id From CorrelationContext, if available
exception.type Exception type name (on failure)
exception.message Exception message (on failure)

When no ActivityListener is registered (tracing disabled), WithActivity() has zero overhead — no spans are created.

OTLP Export

The OTLP exporter reads its endpoint from the OTEL_EXPORTER_OTLP_ENDPOINT environment variable, which .NET Aspire sets automatically. For non-Aspire deployments, set it manually or use the OtlpEndpoint config property:

{
  "Deepstaging": {
    "Telemetry": {
      "Enabled": true,
      "OtlpEndpoint": "http://otel-collector:4317"
    }
  }
}

Metrics

When metrics are enabled, each runtime registers an EffectMetrics instance with three instruments:

Instrument Type Unit Description
effects.succeeded Counter operations Successful effect executions
effects.failed Counter operations Failed effect executions
effects.duration Histogram ms Effect execution duration

All instruments are tagged with the operation name for per-operation breakdown.

The meter is named Deepstaging.Effects and is registered with AddOpenTelemetry().WithMetrics(...).


Logging

When logging is enabled via EnableLogging(), each effect method logs:

  • On success — structured log at Information level with operation name and duration
  • On failure — structured log at Error level with exception details

Logging uses the runtime's ILoggerFactory (resolved via IHasLoggerFactory). A dedicated logger category is created per module.


Aspire Integration

When running under .NET Aspire, observability works automatically:

  1. Aspire sets OTEL_EXPORTER_OTLP_ENDPOINT pointing at the Aspire dashboard's collector
  2. Deepstaging:Telemetry:Enabled = true activates tracing + metrics
  3. Traces appear in the Aspire dashboard under the Traces tab
  4. Each bounded context's spans are grouped by their ActivitySource name
eShop.Aspire/Program.cs
var builder = DistributedApplication.CreateBuilder(args);

var postgres = builder.AddPostgres("postgres")
    .WithLifetime(ContainerLifetime.Persistent);

var serviceBus = builder.AddAzureServiceBus("messaging")
    .RunAsEmulator(emulator => emulator.WithLifetime(ContainerLifetime.Persistent))
    .ConfigureServiceBusTopology();

builder.AddProject<Projects.eShop_AppHost>("api")
    .WithReference(postgres.AddDatabase("eshopdb"))
    .WithReference(serviceBus)
    .WaitFor(postgres);

builder.Build().Run();

No ServiceDefaults project needed — Deepstaging handles the OpenTelemetry wiring internally.


Generated Code

The generated bootstrapper handles all OpenTelemetry registration. Here's what the config-driven path produces:

// After configure?.Invoke(options):
if (builder.Configuration.GetValue<bool>("Deepstaging:Telemetry:Enabled"))
{
    options.Tracing = true;
    options.Metrics = true;  // only if runtime has instrumented modules
}

// Tracing registration:
if (options.Tracing)
{
    services.AddOpenTelemetry().WithTracing(tracing => tracing
        .AddSource("MyApp.OrderDispatch")
        .AddSource("MyApp.OrderStore")
        .AddSource("Microsoft.AspNetCore"));

    Deepstaging.Telemetry.OtlpExporterSetup.EnsureConfigured(services);
}

// Metrics registration:
if (options.Metrics)
{
    services.AddSingleton(new EffectMetrics("MyApp.OrderRuntime"));
    services.AddOpenTelemetry()
        .WithMetrics(metrics => metrics.AddMeter("Deepstaging.Effects"));
}

OtlpExporterSetup.EnsureConfigured() is a static once-guard — UseOtlpExporter() runs on the first call and is a no-op on subsequent calls from other runtimes.

Correlation Context

CorrelationContext provides request-scoped metadata that flows automatically through the entire pipeline — from HTTP endpoints, through dispatch handlers, into event queues, and out via outbound HTTP calls.

The Context

public sealed record CorrelationContext
{
    public string? CorrelationId { get; set; }   // Unique per request chain
    public string? UserId { get; set; }          // Authenticated user
    public string? TenantId { get; set; }        // Multi-tenant identifier
    public string? CausationId { get; set; }     // Command that caused this event
    public IReadOnlyDictionary<string, string>? Metadata { get; set; }
    public static CorrelationContext? Current { get; set; }  // AsyncLocal<T> ambient
}

Propagation Flow

HTTP Request
  │  Middleware extracts/creates CorrelationContext
  │  Sets CorrelationContext.Current (AsyncLocal)
Dispatch Handler
  │  WithActivity reads context → enriches Activity span with:
  │    correlation.id, user.id, tenant.id, causation.id
Event Queue (if events enqueued)
  │  EventQueueChannel captures CorrelationContext in EventEnvelope
  │  ChannelWorker restores context before handler execution
Outbound HTTP (if handler calls external APIs)
     CorrelationDelegatingHandler sets headers:
       ds-correlation-id, ds-causation-id, ds-tenant-id

Dual Mechanism

The context is available through two complementary mechanisms:

  1. Ambient (AsyncLocal)CorrelationContext.Current is available anywhere without parameter threading. Used by middleware, delegating handlers, and hand-written code.
  2. Explicit (runtime capability)IHasCorrelationContext on the runtime type. Used by generated effect methods that need compile-time guarantees.

Headers Propagated

Header Source Description
ds-correlation-id CorrelationContext.CorrelationId Traces a request across services
ds-causation-id CorrelationContext.CausationId The command that caused this action
ds-tenant-id CorrelationContext.TenantId Multi-tenant identifier

These headers are set automatically by CorrelationDelegatingHandler on every outbound HTTP call made through generated HTTP clients.