# dcontext-dactor
Automatic [dcontext](https://crates.io/crates/dcontext) propagation through
[dactor](https://crates.io/crates/dactor) actor messages.
[](https://crates.io/crates/dcontext-dactor)
[](https://docs.rs/dcontext-dactor)
When actors send messages to each other — locally or across the network —
distributed context (request IDs, tenant info, feature flags, etc.) needs to
travel with those messages. `dcontext-dactor` makes this transparent by
providing inbound and outbound interceptors that handle serialization,
deserialization, and scope restoration automatically.
## Quick Start
```toml
[dependencies]
dcontext = "0.8"
dcontext-dactor = "0.8"
dactor = "0.3.1"
```
```rust
use dcontext::async_ctx;
use dcontext_dactor::{ContextInboundInterceptor, ContextOutboundInterceptor};
// Register interceptors with your dactor runtime
runtime.add_outbound_interceptor(Box::new(ContextOutboundInterceptor::default()));
runtime.add_inbound_interceptor(Box::new(ContextInboundInterceptor::default()));
// That's it! Context now flows automatically between actors.
// Inside any async actor handler:
let rid = async_ctx::get_context::<RequestId>("request_id").unwrap();
```
## How It Works
### Two-Stage Pipeline
The crate uses a **two-stage pipeline** that separates header normalization
from context restoration:
```text
Sender Receiver
────── ────────
OutboundInterceptor InboundInterceptor
├─ local target? Stage 1: on_receive()
│ └─ attach ContextSnapshot ├─ wire header? deserialize → snapshot
└─ remote target? └─ normalize to ContextSnapshotHeader
└─ serialize → ContextHeader Stage 2: wrap_handler()
(includes scope chain) ├─ enter named scope "remote:<actor_name>"
└─ wrap future with dcontext::async_ctx::with_context()
```
**Outbound** — The `ContextOutboundInterceptor` captures the current context
and attaches it to the message headers. For **local** targets, it uses a
zero-copy snapshot (preserving local-only values). For **remote** targets, it
serializes to wire bytes.
**Inbound** — The `ContextInboundInterceptor` works in two stages:
1. `on_receive` normalizes incoming headers (deserializes wire bytes into a snapshot)
2. `wrap_handler` runs the handler future inside `dcontext::async_ctx::with_context()`,
so context is automatically available inside the handler — no manual restoration needed.
### Local vs Remote Propagation
| Same process | `ContextSnapshotHeader` | None | ✅ Preserved |
| Cross-network | `ContextHeader` | Bincode bytes | ❌ Excluded |
## Error Handling
Both interceptors accept an `ErrorPolicy`:
```rust
use dcontext_dactor::{ContextOutboundInterceptor, ContextInboundInterceptor, ErrorPolicy};
// Log warnings and continue (default) — messages are delivered even if
// context serialization/deserialization fails
let outbound = ContextOutboundInterceptor::default();
let inbound = ContextInboundInterceptor::default();
// Reject — message is dropped if context cannot be propagated
let outbound = ContextOutboundInterceptor::new(ErrorPolicy::Reject);
let inbound = ContextInboundInterceptor::new(ErrorPolicy::Reject);
```
## Wire Transport Registration
If your actors communicate over the network, register the context header
deserializer with dactor's `HeaderRegistry`:
```rust
use dcontext_dactor::register_context_headers;
let mut header_registry = dactor::HeaderRegistry::new();
register_context_headers(&mut header_registry);
// Pass header_registry to your dactor transport configuration
```
## Manually Extracting Context
If you need the propagated context snapshot for spawning sub-tasks or other
manual use, use `extract_context`:
```rust
use dcontext::async_ctx;
use dcontext_dactor::extract_context;
async fn my_handler(ctx: &ActorContext, msg: MyMessage) {
if let Some(snapshot) = extract_context(ctx) {
tokio::spawn(async move {
async_ctx::with_context(snapshot, async {
// sub-task has the same context
}).await;
});
}
}
```
## Full Example
See [`samples/src/bin/dactor_propagation.rs`](../samples/src/bin/dactor_propagation.rs)
for a complete working example.
## Scope Chain Integration
The inbound interceptor's `wrap_handler` automatically creates a named scope
`remote:<actor_name>` for each inbound message. This makes distributed call
boundaries visible in the scope chain:
```rust
// Caller (sync code shown here) sets up context and sends a message:
let _guard = dcontext::sync_ctx::enter_named_scope("api-gateway");
actor_ref.send(MyMessage { ... }).await;
// Inside the receiving actor handler (async):
let chain = dcontext::async_ctx::scope_chain();
// chain == ["api-gateway", "remote:OrderActor"]
```
This gives you a full distributed call path without manual instrumentation.
The scope chain propagates through serialization (wire format v2), so the
chain accumulates as requests traverse multiple services and actors.
## API Reference
| `ContextOutboundInterceptor` | Captures context on message send |
| `ContextInboundInterceptor` | Restores context on message receive (two-stage) |
| `ContextHeader` | Serialized wire header (`"dcontext.wire"`) |
| `ContextSnapshotHeader` | Local snapshot header (`"dcontext.snapshot"`) |
| `ErrorPolicy` | `LogAndContinue` (default) or `Reject` |
| `extract_context()` | Manually extract propagated context |
| `register_context_headers()` | Register wire deserializer with `HeaderRegistry` |
## Related
- [dcontext](https://crates.io/crates/dcontext) — Core context propagation library
- [dcontext-tracing](https://crates.io/crates/dcontext-tracing) — Tracing span integration
- [Usage Guide](https://github.com/microsoft/dcontext/blob/main/docs/usage-guide.md)
## License
MIT