Correlation and causation IDs
CQRS ES
Every command and event in Protean carries two tracing identifiers --
correlation_id and causation_id -- that let you reconstruct the full
causal history of any business operation. This guide covers how the IDs
are generated, how they propagate, how to supply them from external callers,
and how every observability layer (logging, OTEL, Observatory) uses them.
The out-of-the-box guarantee
Protean generates and propagates correlation and causation IDs automatically.
No opt-in configuration, no manual wiring. The moment you call
domain.process(), every command and event in the resulting chain carries
both IDs:
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?"
# This is all you need. Both IDs are set automatically.
domain.process(PlaceOrder(customer_id="cust-123", items=items))
When no external correlation_id is provided, Protean generates a UUID4 hex
string (32 characters, no dashes). Every downstream event and command inherits
it. You never need to check for None.
Passing correlation IDs from external callers
In production, the correlation_id typically originates outside Protean --
from an API gateway, frontend client, or upstream service. There are two ways
to pass it in.
Explicitly via domain.process()
Pass the correlation_id parameter directly:
domain.process(
PlaceOrder(customer_id="cust-123", items=items),
correlation_id="req-abc-123-from-gateway",
)
Automatically via HTTP headers
When using the DomainContextMiddleware with FastAPI, the middleware
extracts X-Correlation-ID (falling back to X-Request-ID) from the
incoming request and stores it in the global context. current_domain.process()
picks it up automatically:
from protean.globals import current_domain
from protean.integrations.fastapi import DomainContextMiddleware
app.add_middleware(
DomainContextMiddleware,
route_domain_map={"/orders": order_domain},
)
@router.post("/orders")
async def place_order(request: PlaceOrderRequest):
# No need to extract headers -- the middleware already did it.
# The correlation ID flows through automatically.
current_domain.process(PlaceOrder(**request.model_dump()))
The middleware also injects X-Correlation-ID into the response, reflecting
the ID that was actually used (from header, explicit param, or auto-generated).
Priority of correlation ID sources
When multiple sources provide a correlation ID, Protean resolves them in this order:
- Explicit
correlation_idparameter ondomain.process()-- highest priority - Parent message context (
g.message_in_context) -- used when an event handler dispatches a new command - HTTP request header (
g.request_correlation_id) -- set byDomainContextMiddleware - Auto-generated UUID4 hex -- fallback when none of the above is present
Full propagation flow
The following diagram shows how correlation_id and causation_id flow
through a typical command-event chain, starting from an HTTP request:
sequenceDiagram
participant Client
participant Middleware as DomainContext<br/>Middleware
participant CP as Command<br/>Processor
participant CH as Command<br/>Handler
participant EH as Event<br/>Handler
participant ES as Event<br/>Store
Client->>Middleware: POST /orders<br/>X-Correlation-ID: ext-123
activate Middleware
Middleware->>Middleware: Store ext-123 in<br/>g.request_correlation_id
Middleware->>CP: domain.process(PlaceOrder)
activate CP
CP->>CP: Resolve correlation_id = ext-123<br/>causation_id = None (root)
CP->>ES: Append PlaceOrder<br/>[corr=ext-123, cause=None]
CP->>CH: PlaceOrderHandler._handle()
activate CH
Note over CH: g.message_in_context = PlaceOrder message
CH->>CH: aggregate.raise_(OrderPlaced)
Note over CH: Event inherits:<br/>corr=ext-123<br/>cause=PlaceOrder.headers.id
CH->>ES: Append OrderPlaced<br/>[corr=ext-123, cause=cmd-id]
deactivate CH
deactivate CP
Middleware->>Client: 200 OK<br/>X-Correlation-ID: ext-123
deactivate Middleware
Note over EH: Server picks up OrderPlaced
EH->>CP: domain.process(ReserveInventory)
activate CP
CP->>CP: Inherit corr=ext-123<br/>from g.message_in_context<br/>cause=OrderPlaced.headers.id
CP->>ES: Append ReserveInventory<br/>[corr=ext-123, cause=evt-id]
deactivate CP
Every message in the chain shares the same correlation_id (ext-123),
while each causation_id points to the immediate parent message.
Causation chain semantics
The two IDs together form a causation tree. The correlation_id is the
tree's label; the causation_id links define the edges:
PlaceOrder [corr=ext-123, cause=None] <-- root command
+-- OrderPlaced [corr=ext-123, cause=PlaceOrder.id]
+-- ReserveInventory [corr=ext-123, cause=OrderPlaced.id]
| +-- InventoryReserved [corr=ext-123, cause=ReserveInventory.id]
+-- NotifyCustomer [corr=ext-123, cause=OrderPlaced.id]
+-- NotificationSent [corr=ext-123, cause=NotifyCustomer.id]
Propagation rules
| 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 dispatches new command | Inherited from event | Event's headers.id |
Where the IDs live
Both IDs are stored in DomainMeta, the domain-specific section of message
metadata:
# On a command or event object
event._metadata.domain.correlation_id # "ext-123"
event._metadata.domain.causation_id # "myapp::order:command-abc123-0"
# On a deserialized Message
message.metadata.domain.correlation_id
message.metadata.domain.causation_id
Traversing the chain programmatically
The event store provides three methods for causation chain traversal:
store = domain.event_store.store
# Walk UP from a message to the root command
chain = store.trace_causation(some_message)
# Returns [root_command, ..., target_message]
# Walk DOWN from a command to find all its effects
effects = store.trace_effects(command_message)
# Returns downstream events/commands in chronological order
# Build the full tree for a correlation ID
root = store.build_causation_tree("ext-123")
# Returns a CausationNode with .children recursively populated
CLI inspection
Follow a causal chain from the terminal:
# Tree view (default) -- shows parent-child causation structure
protean events trace "ext-123" --domain=myapp
# Flat table view -- chronological list with trace columns
protean events trace "ext-123" --flat --domain=myapp
Service boundary handling
When one Protean service consumes events from another via subscribers, the correlation chain must be preserved across the boundary. Protean handles this automatically.
Automatic bridging in subscribers
When the server processes an incoming broker message, it extracts the
correlation_id from the message's metadata.domain.correlation_id and
sets it on the processing context. If the external message has no
correlation ID, a fresh UUID is generated so the chain within the consuming
service is still fully traced.
@domain.subscriber(channel="payments")
class PaymentSubscriber:
def __call__(self, message):
# The correlation_id from the source service is already in context.
# Any commands dispatched here inherit it automatically.
domain.process(
ConfirmPayment(order_id=message["order_id"]),
)
Nested command preservation
When a subscriber dispatches multiple commands within a single handler,
Protean preserves the outer message context across each domain.process()
call. This prevents the second command from losing the original correlation
chain:
@domain.subscriber(channel="fulfillment")
class FulfillmentSubscriber:
def __call__(self, message):
# Both commands inherit the same correlation_id from the broker message.
domain.process(ReserveInventory(order_id=message["order_id"]))
domain.process(NotifyWarehouse(order_id=message["order_id"]))
Relationship to OpenTelemetry
Protean maintains two distinct tracing layers that complement each other:
| Layer | Location | Purpose | Format |
|---|---|---|---|
| Domain tracing | DomainMeta.correlation_id + causation_id |
Business operation tracking | Flexible strings |
| Distributed tracing | MessageHeaders.traceparent |
Infrastructure span tracking (Jaeger, Datadog) | W3C 32-hex / 16-hex |
The correlation_id bridges both layers -- it identifies the same business
operation whether you're looking at domain metadata or a Jaeger trace.
When OTel is enabled, Protean sets protean.correlation_id on all major
spans, and protean.causation_id wherever a single parent message is
well-defined. This lets you filter and group spans by business operation in
your APM dashboard.
OTEL span attribute reference
The following spans carry correlation and causation ID attributes:
| Span name | protean.correlation_id |
protean.causation_id |
|---|---|---|
protean.command.process |
Yes | -- |
protean.handler.execute |
Yes | Yes |
protean.uow.commit |
Yes | Yes |
protean.outbox.process |
Yes (when uniform) | Yes (when uniform) |
protean.outbox.publish |
Yes | Yes |
"When uniform" means the attribute is set only when all messages in a batch share the same ID.
For the complete span catalog, see the OpenTelemetry integration guide.
Viewing correlation chains in Observatory
The Protean Observatory includes correlation_id and causation_id in
every MessageTrace event. This enables real-time grouping and filtering
of trace events by business operation.
Timeline view
The Observatory Timeline page supports deep-linking by correlation ID:
http://localhost:9000/timeline?correlation=ext-123
This opens the correlation chain sub-view, showing a vertical causation tree of all messages in the chain.
Trace events that carry correlation context
Most trace event types include correlation_id and causation_id when
available. Handler and outbox events always carry these fields; acknowledgment
and dead-letter events may have null values when correlation context is
not available at the point of emission.
| Trace event | Description |
|---|---|
handler.started |
Handler began processing a message |
handler.completed |
Handler finished successfully |
handler.failed |
Handler raised an exception |
pm.transition |
Process manager state transition |
outbox.published |
Message published from outbox |
outbox.failed |
Outbox publish failure |
message.acked |
Message acknowledged |
message.dlq |
Message moved to dead letter queue |
For details on the Observatory, see the Observability reference.
Structured logging setup
Protean provides automatic correlation context injection for both stdlib
logging and structlog. Every log record emitted during message processing
includes correlation_id and causation_id fields -- no manual threading
required.
stdlib logging
Add the ProteanCorrelationFilter to any handler:
import logging
from protean.integrations.logging import ProteanCorrelationFilter
handler = logging.StreamHandler()
handler.addFilter(ProteanCorrelationFilter())
handler.setFormatter(
logging.Formatter(
"%(levelname)s %(message)s correlation_id=%(correlation_id)s "
"causation_id=%(causation_id)s"
)
)
logging.getLogger().addHandler(handler)
structlog
Add the protean_correlation_processor to your processor pipeline:
import structlog
from protean.integrations.logging import protean_correlation_processor
structlog.configure(
processors=[
protean_correlation_processor,
structlog.dev.ConsoleRenderer(),
]
)
With configure_logging()
The domain's configure_logging() convenience method wires up both the
standard library filter and the protean_correlation_processor in one call.
Use the extra_processors argument only for additional structlog processors:
import structlog
domain.configure_logging(
extra_processors=[
structlog.processors.TimeStamper(fmt="iso"),
],
)
Safe when no context is active
Both integrations check for an active domain context and a
g.message_in_context before reading IDs. When no context is available
(e.g., during startup or in a background thread), both fields default to
"" so formatters never raise KeyError.
Summary
| Aspect | Detail |
|---|---|
correlation_id format |
Flexible string (UUID4 hex default, or any caller-provided string) |
causation_id format |
Protean message ID (e.g., myapp::order:command-abc123-0) |
| Auto-generation | Always. Every chain has a correlation_id even if none is supplied |
| HTTP header | X-Correlation-ID (fallback: X-Request-ID), extracted by DomainContextMiddleware |
| Cross-service | Automatic bridging through subscribers; generates fresh UUID when source has none |
| OTEL spans | protean.correlation_id and protean.causation_id on all major spans |
| Observatory | Every MessageTrace event carries both IDs; Timeline supports correlation deep-links |
| Logging | ProteanCorrelationFilter (stdlib) and protean_correlation_processor (structlog) |
| CLI | protean events trace <correlation_id> with tree and flat views |
| Programmatic API | trace_causation(), trace_effects(), build_causation_tree() |
See also
Pattern: Message Tracing in Event-Driven Systems -- Design considerations, format decisions, and multi-service strategies.
Guide: Message Tracing -- How-to guide for setting up tracing, accessing trace IDs, and using the test DSL.
Guide: OpenTelemetry Integration -- Full span catalog, metrics, APM setup, and TraceParent propagation.
Guide: Structured Logging -- Environment-aware logging configuration, context variables, and method call tracing.
Reference: Observability -- TraceEmitter, Observatory server, SSE streaming, and Prometheus metrics.
Reference: protean events trace -- CLI command for following causal chains.