Rust SDK · Middleware and interceptors
An adapter installs a per-request Event (reachable with the core current(),
or the axum CurrentEvent extractor), auto-populates the actor (via your
resolver) and origin (from headers), and records the event after the handler
completes. Handlers just set action / target on it and the adapter does the
rest.
Cargo features are the isolation mechanism: each adapter is gated behind its own feature, so a binary that only needs axum never pulls in actix-web or tonic.
| Framework | Cargo feature | Type |
|---|---|---|
| axum | features = ["axum"] |
EverscribeLayer::new(recorder, resolve) |
| actix-web | features = ["actix"] |
EverscribeLayer::new(recorder, resolve) |
| tonic (gRPC) | features = ["tonic"] |
EverscribeGrpcLayer::new(recorder, resolve) |
Actor Resolver
The layer takes a resolver: a function from the request Parts to an Actor.
It receives the parts, so identity can come off a session set by an earlier
layer, a JWT, or (here) a header.
use everscribe::event::Actor;
use http::request::Parts;
fn resolve_actor(parts: &Parts) -> Actor {
match parts.headers.get("x-demo-actor").and_then(|v| v.to_str().ok()) {
Some(id) => Actor { r#type: "user".into(), id: id.into(), ..Default::default() },
None => Actor::new("anonymous"),
}
}
Wiring it up
Add the layer to your router:
use axum::{routing::post, Router};
use everscribe::axum::EverscribeLayer;
let app = Router::new()
.route("/api/secrets/:id/reveal", post(reveal))
.layer(EverscribeLayer::new(rec, resolve_actor));
Handlers pull the event with the CurrentEvent extractor and mutate it via
with:
use everscribe::axum::CurrentEvent;
async fn reveal(ev: CurrentEvent /* , ... */) -> impl axum::response::IntoResponse {
ev.with(|e| e.action = "secret.reveal".into());
// ...
}
Ordering. If your resolver reads a session, that session layer must run before (outer). In axum, layers added later wrap the outside, so add the session layer after
EverscribeLayer.
Other frameworks
actix-web
Behind the actix feature, everscribe::actix::EverscribeLayer wraps an actix-web app the same way the axum one wraps a Router:
use actix_web::{post, App};
use everscribe::actix::EverscribeLayer;
use everscribe::event::{self, Actor};
#[post("/login")]
async fn login() -> &'static str {
event::current().with(|e| e.action = "user.login".into());
"ok"
}
let app = App::new()
.wrap(EverscribeLayer::new(rec, |req: &actix_web::dev::ServiceRequest| {
match req.headers().get("x-demo-actor").and_then(|v| v.to_str().ok()) {
Some(id) => Actor { r#type: "user".into(), id: id.into(), ..Default::default() },
None => Actor::new("anonymous"),
}
}))
.service(login);
actix-web handlers are plain async functions, not FromRequestParts-style extractors, so unlike axum this adapter needs no CurrentEvent wrapper: reach the event with the core event::current() directly.
actix-web handlers construct and return one complete HttpResponse rather than mutating a shared writer, and don't implement Responder for bare (), so there's no "handler returned having written nothing" ambiguity here the way there is for sdk-go's gin/echo or fasthttp-based adapters. A handler returning Result<(), E> gets a real, deliberately-chosen 204 No Content on Ok(()), not a fabricated default.
gRPC
Behind the tonic feature, everscribe::tonic::EverscribeGrpcLayer wraps a tonic-generated <Service>Server<T>:
use everscribe::event::{self, Actor};
use everscribe::tonic::EverscribeGrpcLayer;
use http::request::Parts;
use tower::Layer;
let wrapped = EverscribeGrpcLayer::new(rec, |parts: &Parts| {
match parts.headers.get("x-demo-actor").and_then(|v| v.to_str().ok()) {
Some(id) => Actor { r#type: "user".into(), id: id.into(), ..Default::default() },
None => Actor::new("anonymous"),
}
})
.layer(grpc_server);
// Inside an RPC handler: event::current().with(|e| e.action = "...".into());
This is a tower::Layer, the same shape as the axum adapter, deliberately - tonic::service::Interceptor only sees the request and has no way to observe the response, so it can't derive an outcome at all. gRPC returns HTTP 200 for RPC-level errors too; the real status lives in the grpc-status trailer, sent after the response body, so this adapter wraps the response body and watches for that trailer (or, for a trailers-only response with no body at all, reads the status straight off the response head).
Unary and server-streaming are implemented and tested. tonic dispatches all four RPC shapes through the same tower::Service::call this layer wraps, so the setup and outcome-capture logic apply uniformly - but client-streaming and bidirectional RPCs haven't been exercised against a real test RPC, so they aren't claimed as verified, even though nothing in the dispatch path suggests they'd behave differently.
One caveat worth knowing for a streaming handler: event::current() only resolves correctly up until the handler returns its response stream - for server-streaming, that's before a single item has been produced, since the stream is polled by the transport afterward. Calling event::current() again from inside the stream body (while yielding items) returns a detached handle. Capture the handle once before returning the stream (let ev = event::current();) and call .with(...) on that captured handle from inside the stream body instead.
Every RPC is recorded by default: action defaults to the full method name (for example /billing.v1.Billing/RefundInvoice), so an RPC records unless a handler clears it. This differs from the axum and actix-web adapters, which record nothing until a handler names the event.
Outcome::code (the Rust SDK's Result.code equivalent) always carries the HTTP equivalent of the gRPC status, never the native gRPC code - Ok maps to 200, NotFound to 404, and so on, the same mapping sdk-go's, sdk-node's, and sdk-python's gRPC adapters use.