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