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.