Skip to content

Message Context & Tracing

Verified by tests

IdentityValueObjectTests, CorrelationIdW3CTests, CorrelationIdTests, MessageContextTests, MessageEnvelopeTests, CallerInfoTests — library CI run #31657041675 (2026-08-13)

Whizbang provides automatic distributed tracing through three key identifiers: MessageId, CorrelationId, and CausationId. These track message relationships across services, enabling powerful observability and debugging.

Core Identifiers

Identifier Purpose Analogy
MessageId Unique ID for this message Social Security Number (unique per person)
CorrelationId Groups related messages in a workflow Family ID (groups related people)
CausationId Parent message that caused this message Parent ID (who caused this person to exist)

Visual Example

graph TB
    U["User clicks "Create Order" button"]
    C["CreateOrder Command<br/>MessageId: msg-001<br/>CorrelationId: corr-abc (generated for this workflow)<br/>CausationId: null (no parent)"]
    E["OrderCreated Event<br/>MessageId: msg-002<br/>CorrelationId: corr-abc (same as command)<br/>CausationId: msg-001 (caused by CreateOrder)"]
    IW["Inventory Worker"]
    PW["Payment Worker"]
    SW["Shipping Worker"]
    NW["Notification Worker"]
    IR["Inventory Reserved<br/>corr-abc<br/>msg-002"]
    PP["Payment Processed<br/>corr-abc<br/>msg-002"]
    SC["Shipment Created<br/>corr-abc<br/>msg-002"]
    ES["Email Sent<br/>corr-abc<br/>msg-002"]

    U --> C
    C -->|"OrderReceptor processes command"| E
    E -->|"Publishes to Azure Service Bus"| IW
    E -->|"Publishes to Azure Service Bus"| PW
    E -->|"Publishes to Azure Service Bus"| SW
    E -->|"Publishes to Azure Service Bus"| NW
    IW --> IR
    PW --> PP
    SW --> SC
    NW --> ES

    style C fill:#fff3cd,stroke:#ffc107
    style E fill:#fff3cd,stroke:#ffc107
    style IW fill:#d4edda,stroke:#28a745
    style PW fill:#d4edda,stroke:#28a745
    style SW fill:#d4edda,stroke:#28a745
    style NW fill:#d4edda,stroke:#28a745
    style IR fill:#fff3cd,stroke:#ffc107
    style PP fill:#fff3cd,stroke:#ffc107
    style SC fill:#fff3cd,stroke:#ffc107
    style ES fill:#fff3cd,stroke:#ffc107

All events share corr-abc - enabling you to query all messages in this workflow!


MessageId

Purpose: Unique identifier for each message (never reused).

Type: Strongly-typed value object using UUIDv7, declared with the [WhizbangId] attribute. The source generator emits the members:

MessageId

[WhizbangId]
public readonly partial struct MessageId;

// Generated members (via the WhizbangId source generator):
// public Guid Value { get; init; }
// public static MessageId New();          // TrackedGuid.NewMedo() — monotonic UUIDv7
// public static MessageId From(Guid);     // validates UUIDv7
// public static MessageId Parse(string);  // validates UUIDv7
// public override string ToString();

Key Characteristics: - Globally unique: No two messages ever have the same ID - Time-ordered: UUIDv7 includes timestamp, sortable by creation time - Database-friendly: Primary keys using MessageId don't fragment indexes - Immutable: Once created, never changes

Usage

Usage

// Whizbang creates MessageId automatically
var receipt = await _dispatcher.SendAsync(command);

Console.WriteLine($"Message ID: {receipt.MessageId}");
// Output: Message ID: 018d8f8e-1234-7890-abcd-ef1234567890

You rarely create MessageId manually - Whizbang handles this.


CorrelationId

Purpose: Groups all messages related to the same workflow/transaction.

Type: Strongly-typed value object over a 128-bit id. Internally-minted ids use UUIDv7 and align to the ambient W3C trace context; externally-supplied ids — an inbound header, a W3C trace-id, or a client crypto.randomUUID (UUIDv4) — are accepted verbatim (a W3C trace-id is 128 bits, so it fits a Guid).

CorrelationId

public readonly partial struct CorrelationId {
    // Fresh UUIDv7 (time-ordered, database-friendly).
    public static CorrelationId New();

    // Adopt the ambient W3C trace-id (Activity.Current.TraceId) so correlation lines up with the
    // OpenTelemetry trace; falls back to New() when no Activity is active.
    public static CorrelationId NewRootAligned();

    // Accept an external token (inbound header / W3C trace-id / non-v7 UUID) without UUIDv7 validation.
    public static CorrelationId FromExternal(Guid value);

    public Guid Value { get; }
    public override string ToString() => Value.ToString();
}

Key Characteristics: - Workflow identifier: All messages in same workflow share same CorrelationId - Cross-service: Spans multiple services, receptors, perspectives - Queryable: Find all messages for a specific customer action - Persistent: Stored in database, logs, telemetry

How CorrelationId Flows

1. User Request → HTTP Request
   CorrelationId: CAPTURED from the inbound X-Correlation-ID header
                  (or minted and aligned to the W3C trace when absent)

2. CreateOrder Command
   CorrelationId: INHERITED from HTTP request

3. OrderCreated Event
   CorrelationId: INHERITED from CreateOrder

4. InventoryReserved Event (in different service)
   CorrelationId: INHERITED from OrderCreated

5. PaymentProcessed Event (in different service)
   CorrelationId: INHERITED from InventoryReserved

... and so on

All messages inherit the same CorrelationId!

Usage

Usage (2)

// The dispatcher mints (or inherits) the correlation id and propagates it to
// every message in the workflow — no manual plumbing needed.
var command = new CreateOrder(customerId, items);
var (result, receipt) = await _dispatcher
    .LocalInvokeWithReceiptAsync<CreateOrder, OrderCreated>(command);

// The receipt carries the workflow's CorrelationId (e.g., the client's X-Correlation-ID).
Console.WriteLine($"Correlation ID: {receipt.CorrelationId}");

Querying by CorrelationId

Querying by CorrelationId

// Find all messages in a workflow
public async Task<Message[]> GetWorkflowMessagesAsync(
    CorrelationId correlationId,
    CancellationToken ct = default) {

    await using var conn = _db.CreateConnection();

    // Assuming messages are stored in event store
    var messages = await conn.QueryAsync<Message>(
        """
        SELECT * FROM wh_event_store
        WHERE correlation_id = @CorrelationId
        ORDER BY created_at
        """,
        new { CorrelationId = correlationId.Value },
        cancellationToken: ct
    );

    return messages.ToArray();
}

Result: Complete trace of every message in the workflow!


CausationId

Purpose: Identifies the parent message that caused this message to exist.

Type: There is no separate CausationId type — a causation id is the parent message's MessageId, so Whizbang uses MessageId directly (e.g., IMessageContext.CausationId is a MessageId).

CausationId

// From src/Whizbang.Core/ValueObjects/CausationId.cs:
// "CausationId is just a MessageId of the parent/causing message.
//  No need for a separate type - use MessageId directly."

public interface IMessageContext {
    MessageId MessageId { get; }
    CorrelationId CorrelationId { get; }
    MessageId CausationId { get; }   // parent's MessageId
    // ...
}

Key Characteristics: - Parent-child relationship: Links message to its creator - Causality chain: Track how one message led to another - Debugging: "What caused this message to be created?" - Nullable: Root messages (HTTP requests) have no parent

Causation Chain Example

graph TB
    M1["CreateOrder Command<br/>MessageId: msg-001<br/>CorrelationId: corr-abc<br/>CausationId: null (no parent)"]
    M2["OrderCreated Event<br/>MessageId: msg-002<br/>CorrelationId: corr-abc<br/>CausationId: msg-001 (caused by CreateOrder)"]
    M3["InventoryReserved Event<br/>MessageId: msg-003<br/>CorrelationId: corr-abc<br/>CausationId: msg-002 (caused by OrderCreated)"]
    M4["PaymentProcessed Event<br/>MessageId: msg-004<br/>CorrelationId: corr-abc<br/>CausationId: msg-003 (caused by InventoryReserved)"]

    M1 -->|"Creates"| M2
    M2 -->|"Creates"| M3
    M3 -->|"Creates"| M4

    style M1 fill:#fff3cd,stroke:#ffc107
    style M2 fill:#fff3cd,stroke:#ffc107
    style M3 fill:#fff3cd,stroke:#ffc107
    style M4 fill:#fff3cd,stroke:#ffc107

Causation chain: msg-001 → msg-002 → msg-003 → msg-004

Usage

Usage - CreateOrderReceptor

// Receptor creates event with causation
public class CreateOrderReceptor : IReceptor<CreateOrder, OrderCreated> {
    public async ValueTask<OrderCreated> HandleAsync(
        CreateOrder message,
        CancellationToken ct = default) {

        // Business logic...

        return new OrderCreated(
            OrderId: Guid.CreateVersion7(),
            CustomerId: message.CustomerId,
            Items: message.Items,
            Total: CalculateTotal(message.Items),
            CreatedAt: DateTimeOffset.UtcNow
        );

        // The event's envelope automatically receives:
        //   MessageId    = new unique ID
        //   CorrelationId = inherited from CreateOrder's envelope
        //   CausationId  = CreateOrder's MessageId (the parent)
    }
}

Whizbang handles the identity stamping automatically via MessageEnvelope hops — you never assign these ids on your domain events.


MessageEnvelope

Whizbang wraps all messages in a MessageEnvelope containing context:

MessageEnvelope

public class MessageEnvelope<TMessage> : IMessageEnvelope<TMessage> {
    public required MessageId MessageId { get; init; }
    public required TMessage Payload { get; set; }         // Your actual message
    public required List<MessageHop> Hops { get; init; }   // Trace hops (each hop carries CorrelationId/CausationId/Scope)
    public int Version { get; init; } = 1;
    public required MessageDispatchContext DispatchContext { get; init; }

    // Correlation and causation are read from the hops:
    public CorrelationId? GetCorrelationId();
    public MessageId? GetCausationId();
}

You rarely interact with MessageEnvelope directly - Whizbang manages it transparently.

Automatic Context Propagation

Automatic Context Propagation

// 1. HTTP Request arrives
[HttpPost("orders")]
public async Task<ActionResult> CreateOrder(
    [FromBody] CreateOrderRequest request,
    CancellationToken ct) {

    // 2. Create command (Whizbang generates MessageId, CorrelationId)
    var command = new CreateOrder(request.CustomerId, request.Items);

    // 3. Dispatch command
    var result = await _dispatcher.LocalInvokeAsync<CreateOrder, OrderCreated>(command, ct);

    // 4. Result event has:
    //    - New MessageId (unique)
    //    - Same CorrelationId (inherited)
    //    - CausationId = command.MessageId (parent reference)

    return CreatedAtAction(nameof(GetOrder), new { orderId = result.OrderId }, result);
}

Whizbang automatically: 1. Generates MessageId for command 2. Generates CorrelationId (or inherits from HTTP context) 3. Sets CausationId to command's MessageId when creating event


Distributed Tracing

Querying Workflow History

Querying Workflow History

public class WorkflowTracer {
    private readonly IDbConnectionFactory _db;

    public async Task<WorkflowTrace> TraceWorkflowAsync(
        CorrelationId correlationId,
        CancellationToken ct = default) {

        await using var conn = _db.CreateConnection();

        // Get all messages in workflow
        var messages = await conn.QueryAsync<TraceMessage>(
            """
            SELECT
                message_id,
                causation_id,
                message_type,
                created_at,
                payload
            FROM wh_event_store
            WHERE correlation_id = @CorrelationId
            ORDER BY created_at
            """,
            new { CorrelationId = correlationId.Value },
            cancellationToken: ct
        );

        return new WorkflowTrace(
            CorrelationId: correlationId,
            Messages: messages.ToArray()
        );
    }
}

public record WorkflowTrace(
    CorrelationId CorrelationId,
    TraceMessage[] Messages
) {
    public void PrintTrace() {
        Console.WriteLine($"Workflow: {CorrelationId}");
        foreach (var msg in Messages) {
            Console.WriteLine($"  {msg.CreatedAt:yyyy-MM-dd HH:mm:ss.fff} | {msg.MessageType}");
            if (msg.CausationId is not null) {
                Console.WriteLine($"    Caused by: {msg.CausationId}");
            }
        }
    }
}

Output:

Workflow: corr-abc
  2024-12-12 10:00:00.123 | CreateOrder
  2024-12-12 10:00:00.456 | OrderCreated
    Caused by: msg-001
  2024-12-12 10:00:01.234 | InventoryReserved
    Caused by: msg-002
  2024-12-12 10:00:02.567 | PaymentProcessed
    Caused by: msg-003
  2024-12-12 10:00:03.890 | ShipmentCreated
    Caused by: msg-004

Visualizing Causation Chains

Visualizing Causation Chains

public class CausationVisualizer {
    public void VisualizeCausationChain(TraceMessage[] messages) {
        var messageMap = messages.ToDictionary(m => m.MessageId);

        foreach (var msg in messages) {
            PrintMessageWithIndent(msg, messageMap, indent: 0);
        }
    }

    private void PrintMessageWithIndent(
        TraceMessage msg,
        Dictionary<Guid, TraceMessage> map,
        int indent) {

        var prefix = new string(' ', indent * 2);
        Console.WriteLine($"{prefix}├─ {msg.MessageType} ({msg.MessageId})");

        // Find children (messages caused by this message)
        var children = map.Values
            .Where(m => m.CausationId == msg.MessageId)
            .ToArray();

        foreach (var child in children) {
            PrintMessageWithIndent(child, map, indent + 1);
        }
    }
}

Output:

├─ CreateOrder (msg-001)
  ├─ OrderCreated (msg-002)
    ├─ InventoryReserved (msg-003)
      ├─ PaymentProcessed (msg-004)
        ├─ ShipmentCreated (msg-005)
          ├─ NotificationSent (msg-006)


Integration with Logging

Structured Logging

Structured Logging

public class CreateOrderReceptor : IReceptor<CreateOrder, OrderCreated> {
    private readonly ILogger<CreateOrderReceptor> _logger;

    public async ValueTask<OrderCreated> HandleAsync(
        CreateOrder message,
        CancellationToken ct = default) {

        // Log with correlation and causation context
        using (_logger.BeginScope(new Dictionary<string, object> {
            ["CorrelationId"] = message.CorrelationId,
            ["CausationId"] = message.CausationId?.ToString() ?? "null",
            ["MessageId"] = message.MessageId
        })) {
            _logger.LogInformation(
                "Processing CreateOrder for customer {CustomerId}",
                message.CustomerId
            );

            // Business logic...

            _logger.LogInformation(
                "Order {OrderId} created successfully",
                orderId
            );

            return new OrderCreated(/* ... */);
        }
    }
}

Log Output (JSON format): Structured Logging (2)

{
  "Timestamp": "2024-12-12T10:00:00.123Z",
  "Level": "Information",
  "Message": "Processing CreateOrder for customer 550e8400-e29b-41d4-a716-446655440000",
  "CorrelationId": "corr-abc",
  "CausationId": "null",
  "MessageId": "msg-001"
}

Benefit: Query logs by CorrelationId to see all log entries for a workflow!

Application Insights Integration

Application Insights Integration

public class OrderReceptor : IReceptor<CreateOrder, OrderCreated> {
    private readonly TelemetryClient _telemetry;

    public async ValueTask<OrderCreated> HandleAsync(
        CreateOrder message,
        CancellationToken ct = default) {

        using var operation = _telemetry.StartOperation<RequestTelemetry>("CreateOrder");
        operation.Telemetry.Properties["CorrelationId"] = message.CorrelationId.ToString();
        operation.Telemetry.Properties["CausationId"] = message.CausationId?.ToString() ?? "null";
        operation.Telemetry.Properties["MessageId"] = message.MessageId.ToString();

        try {
            // Business logic...

            operation.Telemetry.Success = true;
            return new OrderCreated(/* ... */);

        } catch (Exception ex) {
            operation.Telemetry.Success = false;
            _telemetry.TrackException(ex);
            throw;
        }
    }
}

Best Practices

DO ✅

  • ✅ Let Whizbang generate MessageId automatically
  • Inherit CorrelationId from parent message
  • Set CausationId to parent's MessageId
  • Log CorrelationId in structured logging
  • Store CorrelationId in database for querying
  • Use CorrelationId for end-to-end workflow tracing
  • Use CausationId for debugging (what caused this?)
  • Propagate CorrelationId across HTTP boundaries

DON'T ❌

  • ❌ Reuse MessageId (must be unique per message)
  • ❌ Change CorrelationId mid-workflow (breaks tracing)
  • ❌ Forget to propagate CorrelationId across services
  • ❌ Use CorrelationId as business identifier (use OrderId, etc.)
  • ❌ Store MessageId in business entities (use domain IDs like OrderId)
  • ❌ Skip logging CorrelationId (critical for debugging)

HTTP Context Integration

ASP.NET Core Middleware

ASP.NET Core Middleware

public class CorrelationIdMiddleware {
    private readonly RequestDelegate _next;

    public CorrelationIdMiddleware(RequestDelegate next) {
        _next = next;
    }

    public async Task InvokeAsync(HttpContext context) {
        // Extract or generate CorrelationId
        var correlationId = context.Request.Headers["X-Correlation-ID"].FirstOrDefault()
            ?? CorrelationId.New().ToString();

        // Store in HttpContext (FromExternal: inbound tokens skip UUIDv7 validation)
        context.Items["CorrelationId"] = CorrelationId.FromExternal(Guid.Parse(correlationId));

        // Add to response headers
        context.Response.Headers["X-Correlation-ID"] = correlationId;

        await _next(context);
    }
}

// Register middleware
app.UseMiddleware<CorrelationIdMiddleware>();

Propagating to Downstream Services

Propagating to Downstream Services

public class HttpClientWithCorrelation {
    private readonly HttpClient _httpClient;
    private readonly IHttpContextAccessor _httpContext;

    public async Task<HttpResponseMessage> PostAsync(string url, HttpContent content) {
        // Get CorrelationId from current request
        var correlationId = _httpContext.HttpContext?.Items["CorrelationId"] as CorrelationId?
            ?? CorrelationId.New();

        // Add to outgoing request
        var request = new HttpRequestMessage(HttpMethod.Post, url) {
            Content = content
        };
        request.Headers.Add("X-Correlation-ID", correlationId.ToString());

        return await _httpClient.SendAsync(request);
    }
}

IMessageContext Interface

The IMessageContext interface provides all context and metadata for a message flowing through the system:

IMessageContext Interface

public interface IMessageContext {
  MessageId MessageId { get; }
  CorrelationId CorrelationId { get; }
  MessageId CausationId { get; }
  DateTimeOffset Timestamp { get; }
  string? UserId { get; }
  string? TenantId { get; }
  IReadOnlyDictionary<string, object> Metadata { get; }
  IScopeContext? ScopeContext { get; }
  ICallerInfo? CallerInfo { get; }
}

ScopeContext

The ScopeContext property carries rich authorization context (Roles, Permissions, SecurityPrincipals, Claims) that the message owns.

Important: The ScopeContext is owned by the message, not read from ambient AsyncLocal. When a message context is created, it captures the current scope context. AsyncLocal then reads from the initiating message context's ScopeContext, not the other way around.

ScopeContext Ownership

// In lifecycle receptors, use ScopeContext from the message context
// because the original HTTP context is unavailable
public class OrderLifecycleReceptor : ILifecycleReceptor<OrderCreatedEvent> {
  public async ValueTask PostPerspectiveAsync(
      OrderCreatedEvent evt,
      IMessageContext context,
      CancellationToken ct) {
    // Use context.ScopeContext for authorization
    var tenantId = context.TenantId;  // From message, not HTTP
    var scope = context.ScopeContext;  // Roles, permissions carried by message
  }
}

CallerInfo

The CallerInfo property captures the caller's source location at dispatch time using [CallerMemberName], [CallerFilePath], and [CallerLineNumber]. This enables click-to-navigate in IDEs like VSCode.

CallerInfo Interface

public interface ICallerInfo {
  string CallerMemberName { get; }  // Method name that dispatched the message
  string CallerFilePath { get; }    // Source file path
  int CallerLineNumber { get; }     // Line number in source file
}

CallerInfo is null when caller info is unavailable (e.g., MessageContext.New() or test contexts).


Further Reading

Core Concepts: - Observability - MessageEnvelope and hops for distributed tracing - Dispatcher - How messages are routed - Receptors - Message handlers - Cascade Context - ScopeContext propagation

Messaging Patterns: - Outbox Pattern - Reliable messaging with context - Message Envelopes - Hop-based observability

Infrastructure: - Logging & Telemetry - Application Insights integration


Version 1.0.0 - Foundation Release | Last Updated: 2024-12-12