Correlation
Every unit of work runs under a W3C trace the framework starts for you — read it anywhere, and follow one request across a stream, a socket and a queue.
Every request, message, job and scheduled tick runs under a W3C Trace Context the framework starts or continues for you. You read it from any code the framework carries — a handler, a service under it, the data layer — and it is already on every event you log.
impl PostService { pub async fn publish(&self, id: Uuid) -> Result<Post, ServiceError> { let post = self.repo.publish(id).await?; // No id is named here, and none should be: the line carries `trace_id`, // `span_id` and `actor_id` already. tracing::info!(target: "features::posts", %id, "post published"); Ok(post) }}Nothing to install and nothing to wire in: this is nest-rs-core, which every
app already links. It needs no collector, no exporter and no authentication — a
primitive that some deployments have and others do not is one nobody can build
on.
What each value names
Section titled “What each value names”| Value | Names | Present |
|---|---|---|
trace_id | the whole distributed operation, across every service it touches | always |
span_id | this unit of work inside it — current_span_id() | always |
actor_id | who it is being served for | once a guard resolved a principal |
The split between the first two is what lets a request that enqueues a job be one trace and two spans: the job’s parent is the request’s span, so “show me everything this request caused” answers across the process boundary.
Every log line carries them, read from the ambient context when the event is emitted — so you never write one by hand, and restating one is a duplicate:
DEBUG features::posts: creating post title="hello" trace_id=01a01569ae687353bc034a9ee8bd8774 span_id=a3f7cc8bac648278 actor_id=01a0112ce24e75509be691162cbbab1fRead them from code when you need the value — a created_by column, an
outbound header, an audit row:
let trace_id = nest_rs::core::current_trace_id();let span_id = nest_rs::core::current_span_id();// `None` for an anonymous caller. Absence is the answer, not a gap.let actor_id = nest_rs::core::current_actor_id();Where a trace comes from
Section titled “Where a trace comes from”Whoever accepts the work decides, and there are two answers: continue one that arrived with the work, or start one.
An inbound traceparent is continued only from a peer in
NESTRS_HTTP__TRUSTED_PROXIES — the same list X-Forwarded-For is weighed
against. From anyone else the trace is restarted, which is what the
specification defines a front gate to do. Without that gate a public client could
file its requests into a trace of its choosing and set the sampled flag on
traffic it generates.
So a deployment behind a proxy that already traces sets that variable, or its
inbound traces stop at your edge. Nothing is lost either way: the caller’s
claimed traceparent is recorded on the span as
http.request.header.traceparent, so you can still join against it.
What the caller reads back is the trace this service actually used:
traceresponse: 00-01a011c406fb7433abbf3a3ddf952fa5-7f15c145ffbe7ed8-03actor_id is an audit identity
Section titled “actor_id is an audit identity”You declare it once, on your principal, and the authentication guard publishes it
the moment it resolves one — see Authentication.
From then on current_actor_id() answers anywhere the framework carries work.
It answers who, for a log line, an audit row, a created_by column. It does
not answer what they may do: that is the ambient Ability, decided in a guard.
Branching on this value in a service is the check the framework exists to keep
out of services.
No sentinel is returned for an anonymous visitor — "" or "anonymous" would be
indistinguishable from an actor genuinely named that, and a query counting
anonymous traffic would silently count both.
It follows the work
Section titled “It follows the work”A unit of work ends when its answer ends, not when its handler returns. That matters at every edge, and the one that surprises people is the first:
| Where | What you get |
|---|---|
inside an #[sse] stream, or any streaming body | the request that opened it, for as long as it streams |
| a WebSocket message | its own span, the connection’s trace, the actor from the upgrade |
a #[on_connect] / #[on_disconnect] hook | a unit of work too — ws.connect, ws.disconnect, under the upgrade’s trace |
| a GraphQL subscription | the trace of the upgrade that opened the socket |
| an MCP operation | the trace of the HTTP request it arrived on |
| a queue job, in another process | the producer’s trace, as a child of the enqueue |
| a scheduled tick | a fresh trace of its own — nothing upstream to continue |
an #[on_event] listener | the span of whatever emitted the event — dispatch is inline, not a unit of work of its own |
A queue crosses a process, so the context travels in the job envelope rather than in a task-local. A payload that carries none — a legacy producer, a foreign system — starts a trace instead of being refused.
Calling another service
Section titled “Calling another service”The framework ships no general outbound HTTP client, by decision — wrapping one costs you every option it has and buys a module. So you inject your own, and propagating the trace is one line in it:
let mut request = self.http.get(url);if let Some(traceparent) = nest_rs::core::current_traceparent() { request = request.header("traceparent", traceparent.to_string());}// Forwarding `tracestate` untouched is a MUST, including when you understand// none of it: a vendor's sampling or routing rides in there, and dropping it// breaks them through you.if let Some(state) = nest_rs::core::current_tracestate().and_then(|s| s.as_str().map(str::to_owned)) { request = request.header("tracestate", state);}The service you call continues your trace, and its spans appear under the request that caused them.
Limits
Section titled “Limits”Noneoff a unit of work. A task you spawn yourself leaves the ambient context behind. Capture it first and re-install it around the work.- A socket inherits identity, never resources. A connection that outlives its upgrade takes the trace and the actor, and opens its own request scope per message — it does not hold the upgrade’s.
- The sampling flag is only as good as your sampler. With no observability stack installed the framework records everything, and says so downstream. Mount OpenTelemetry and the flag reports what your sampler decided.
- A restarted trace is a deliberate break. At a front gate that is the point;
if you meant to continue, the peer belongs in
TRUSTED_PROXIES.
Going further
Section titled “Going further”- OpenTelemetry — exporting these traces, sampling, and joining other systems’ telemetry.
- Logs — the access log and the structured-field rules.
- Authentication — declaring the
actor_idyour principal reports.