Message Tracing
Applies to: CQRS ยท Event Sourcing
In event-driven systems, a single user action can trigger a chain of commands and events that cascade across multiple aggregates and handlers. Message tracing lets you follow the full causal chain -- from the initial request through every command and event it spawns -- using two identifiers that Protean attaches to every message automatically:
correlation_id: A constant identifier shared by every message in the chain. It answers: "Which business operation does this message belong to?"causation_id: Theheaders.idof the immediate parent message. It answers: "What directly caused this message?"
Together, they let you reconstruct the full tree of messages for any request, which is invaluable for debugging, auditing, and understanding system behavior.
How It Works
When you submit a command via domain.process(), Protean generates a
correlation_id (a UUID4 hex string, 32 characters) and sets causation_id
to None (since this is the root of the chain). As the command triggers events
and those events trigger further commands, both IDs are propagated automatically:
PlaceOrder (correlation=a1b2c3d4, causation=None) <-- root
+-- OrderPlaced (correlation=a1b2c3d4, causation=cmd-123)
+-- ReserveInventory (correlation=a1b2c3d4, causation=evt-456)
+-- InventoryReserved (correlation=a1b2c3d4, causation=cmd-789)
Every message shares the same correlation_id, while each causation_id
points to the message that directly caused it.
Supplying an External Correlation ID
In production, the correlation_id often originates outside Protean -- from an
API gateway, frontend client, or upstream service. You can pass it into
domain.process():
# Accept correlation_id from the HTTP request header
correlation_id = request.headers.get("X-Correlation-ID")
domain.process(
PlaceOrder(customer_id="cust-123", items=items),
correlation_id=correlation_id,
)
When a correlation_id is provided, Protean uses it as-is instead of
generating a new one. This lets you trace a request across service boundaries.
If no correlation_id is provided, Protean auto-generates one (UUID4 hex, no
dashes).
Where Trace IDs Are Stored
Both IDs live in DomainMeta, the domain-specific section of message metadata:
{
"_metadata": {
"headers": {
"id": "myapp::order-abc123-0.1",
"type": "MyApp.OrderPlaced.v1",
"stream": "myapp::order-abc123",
"time": "2026-02-22T10:30:00+00:00"
},
"domain": {
"fqn": "myapp.events.OrderPlaced",
"kind": "EVENT",
"version": "v1",
"sequence_id": "0.1",
"correlation_id": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6",
"causation_id": "myapp::order:command-abc123"
}
}
}
Accessing Trace IDs in Code
# On a command or event object
event._metadata.domain.correlation_id
event._metadata.domain.causation_id
# On a deserialized Message
message.metadata.domain.correlation_id
message.metadata.domain.causation_id
Propagation Rules
The trace context flows through all message construction paths:
| Entry point | correlation_id |
causation_id |
|---|---|---|
domain.process(cmd) |
Auto-generated or caller-provided | None (root) |
domain.process(cmd, correlation_id="ext-123") |
"ext-123" |
None (root) |
| Command handler raises event | Inherited from command | Command's headers.id |
| Event handler processes new command | Inherited from event | Event's headers.id |
The propagation happens through Protean's global context (g.message_in_context),
which the engine sets before invoking each handler. This works in both
synchronous and asynchronous processing modes.
Relationship to W3C TraceParent
Protean maintains two distinct tracing layers:
| Layer | Location | Purpose | Format |
|---|---|---|---|
| Domain tracing | DomainMeta.correlation_id + causation_id |
Protean-internal causation chain | Flexible strings |
| Distributed tracing | MessageHeaders.traceparent |
External tool integration (Jaeger, Zipkin) | W3C hex format |
correlation_id bridges both layers -- it identifies the same business
operation whether you're looking at Protean's domain metadata or a W3C trace.
causation_id, however, is domain-layer only because Protean's message IDs
(like "myapp::order-abc123-0.1") are intentionally human-readable and cannot
fit in W3C's 16-hex-char parent_id format.
Using Trace IDs in the Test DSL
The test DSL supports correlation IDs for verifying trace propagation:
from protean.testing import given
result = (
given(Order)
.when(PlaceOrder(customer_id="cust-1", items=[...]))
.then_events(OrderPlaced)
)
# Follow the chain with the same correlation_id
result.process(
ReserveInventory(order_id=result.aggregate.id),
correlation_id=result.events[0]._metadata.domain.correlation_id,
)
Inspecting Traces via CLI
The protean events CLI commands support a --trace flag to display
correlation and causation IDs alongside event data:
# Read events with trace context
protean events read "myapp::order-abc123" --trace --domain=myapp
# Search events with trace context
protean events search --type=OrderPlaced --trace --domain=myapp
To follow an entire causal chain by correlation ID:
protean events trace "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6" --domain=myapp
This scans all events matching the given correlation_id and displays them in
chronological order, showing the full causation tree.
See protean events for the complete CLI
reference.
Outbox Integration
When events are committed to the outbox (for reliable message delivery), the
correlation_id and causation_id are denormalized into the outbox record
for efficient querying:
# Find all outbox messages for a business operation
outbox_repo.find_by_correlation_id("a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6")
# Find all messages caused by a specific message
outbox_repo.find_by_causation_id("myapp::order-abc123-0.1")
This is useful for operational dashboards and debugging delivery issues.
See also
Pattern: Message Tracing in Event-Driven Systems -- Design considerations, when to use external vs generated IDs, and multi-service tracing strategies.
Reference: protean events trace -- CLI command for following causal chains.
Concept: Observability -- Real-time tracing and monitoring with the Protean Observatory.