Rust SDK · Behaviors worth knowing
Empty action is a no-op
Events with no action are dropped at send time (and the middleware skips
auto-record). Set action only when something audit-worthy happens:
Dropping an unnamed event is a deliberate opt-out for an outcome that is not audit-worthy, not a way to skip failures. The difference is whether an auditor would want to see the exit path.
A read that found nothing, or a malformed request that reached no resource, is
not audit-worthy: leave action unset. That is what the handler below does on
its 404.
A failed attempt at an audit-worthy action is the opposite. A denied authorization, a rejected payment, an invite that errored: all of those must be recorded, which means naming the event before the early return that handles them.
async fn get_secret(State(v): State<Vault>, ev: CurrentEvent, Path(id): Path<String>) -> Response {
let Some(s) = v.get(&id) else {
return (StatusCode::NOT_FOUND, "not found").into_response(); // no action -> dropped
};
ev.with(|e| {
e.action = "secret.read".into();
e.target = Target::new("secret", &s.id);
});
Json(s.to_wire()).into_response()
}
Naming an event whose target does not exist yet
When the target's ID only exists after the operation succeeds, name the event
anyway and backfill the ID. Moving the naming below the error return is what
silently drops the failure. CurrentEvent is a handle, so both the naming and
the backfill go through ev.with(...):
use everscribe::event::Target;
async fn invite_member(State(m): State<Members>, ev: CurrentEvent, Json(body): Json<Invite>) -> Response {
ev.with(|e| {
e.action = "member.invited".into();
e.target = Target::new("user", ""); // no ID yet, and that is fine
});
let u = match m.invite(body).await {
Ok(u) => u,
// recorded, outcome filled by the layer
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "invite failed").into_response(),
};
ev.with(|e| e.target.id = u.id.clone()); // backfilled on the success path
Json(u.to_wire()).into_response()
}
An event whose target has a type but no id is still a useful audit record: it says who tried to do what, and that it failed. An event that was never named says nothing at all.
The rule generalizes past target.id. Any field you cannot populate until the
operation succeeds gets assigned after the error return, never used as a reason
to delay naming the event.
record() is non-blocking
record(...).await enqueues the event on the in-memory buffer and returns; a
background tokio task does the HTTP flush. It only actually awaits under
OverflowPolicy::Block when the buffer is full. Don't treat it as a network
round-trip.
Caller-set fields take precedence
The middleware fills result from the response status only when you haven't set
one. Set outcome yourself for anti-enumeration handlers that return the same
status for different audit outcomes:
use everscribe::event::{Outcome, Target};
async fn read(State(v): State<Vault>, ev: CurrentEvent, Path(id): Path<String>) -> Response {
ev.with(|e| { e.action = "secret.reveal".into(); e.target = Target::new("secret", &id); });
match v.get(&id) {
None => (StatusCode::NOT_FOUND, "not found").into_response(), // records result: error / 404
Some(s) if s.tenant_id != current_tenant => {
// Return 404 instead of 403 so attackers can't probe cross-tenant IDs.
ev.with(|e| e.outcome = Outcome { status: "denied".into(), code: 403, ..Default::default() });
(StatusCode::NOT_FOUND, "not found").into_response()
}
Some(s) => Json(json!({ "value": s.value })).into_response(),
}
}