Context is Suprnova's per-request key/value bag. It's where you stash
data you want every downstream caller in the same request to see - a
request id, a tenant slug, a user role, an audit trail - without
threading the value through every function signature. It's the Suprnova
equivalent of Laravel's Context facade.
use Context;
add;
push;
hidden_add;
let tenant: = get;
let page: = query_param;
Reach for it when:
- A log line, queued job, or broadcast message needs request-scoped metadata (tenant id, correlation id, user role)
- A deeply-nested helper needs a value the handler already has, but the call chain shouldn't carry a parameter through every layer
- You want to read the current request's query string (
?page=3,?cursor=…) from code that isn't a handler
Context is not for cross-request state. It's bound to the
current Tokio task and disappears when the request ends. The one
exception is work you queue: a job, a queued mail or notification, and a
queued event listener each receive a copy of the context of the code that
queued them - see Queued work. For things that outlive a
request, use the Service Container or Cache.
The two bags
Every active Context scope carries two key/value maps and one extra
slot:
| Bag | Read with | Appears in Context::all() |
|---|---|---|
| Visible | Context::get |
Yes |
| Hidden | Context::hidden_get |
No |
| Query | Context::query_param |
No (separate snapshot of the URL's ?key=value pairs) |
The split between visible and hidden is the whole point of having
two bags: log serialisers that dump Context::all() into structured
output won't leak data you intentionally hide. Put audit metadata in
the visible bag; put API keys, OAuth bearer tokens, and PII you don't
want in logs in the hidden bag.
The query bag is populated automatically by the framework's request middleware from the URL's query string (see Pagination reads query params below). You usually only read it, never write it.
The active scope
A Context scope is installed by the framework on every incoming
HTTP request, and by a queue worker around every attempt of a job.
Inside a handler, middleware, model observer, event listener, job, or
anything else reachable from those tasks, the scope is live and
Context::* reads and writes work without ceremony.
Outside a scope - early-boot code, a bare tokio::spawn that doesn't
inherit context, a unit test that doesn't install one - every
mutation is a silent no-op and every read returns None. The
contract is: no panic, ever, regardless of where you call from.
// In a handler - scope is active, everything works:
add;
let id: = get;
assert_eq!;
// Outside a scope - silent no-op + None:
add; // discarded
let id: = get;
assert_eq!;
The no-panic contract is deliberate. Library code that touches
Context (a custom log subscriber, an SDK extension) shouldn't need
to know whether it's running inside a request or at boot - it should
just call Context::get and treat None as "not available right now".
Observability for silent operations
A truly silent no-op would hide bugs (middleware out of order,
context not propagated into a spawned task, accidental boot-time
read). The framework's mutating operations stay no-panic but emit a
tracing::trace! event on the suprnova::context target whenever
they discard:
TRACE suprnova::context: Context mutation discarded: no active scope on this task op="add"
TRACE suprnova::context: Context mutation discarded: value failed to serialize op="push" key="bad"
TRACE suprnova::context: Context read returned None: value present but did not deserialize op="get" key="user_id" expected="String"
Three classes of event:
| Event | When it fires |
|---|---|
mutation discarded: no active scope |
add, push, hidden_add, forget called outside any scope |
mutation discarded: value failed to serialize |
add/push/hidden_add value's Serialize impl errored |
read returned None: value present but did not deserialize |
get/hidden_get found the key but the stored JSON doesn't match the requested T |
Plain absence - get on a key that was never set - stays silent so
"is this set?" probes don't flood logs. Enable
RUST_LOG=suprnova::context=trace when you suspect a propagation
bug; the silent no-op path becomes visible without changing how
production code behaves.
Adding values
Context::add - replace at a key
use Context;
add;
add;
add; // any Serialize value
The key is Into<String>; the value is any Serialize type. The
value is converted to serde_json::Value once at write time and
stored that way. Subsequent add on the same key replaces.
Context::push - append to a stack
push;
push;
push;
let trail: = get.unwrap;
assert_eq!;
push initialises an empty array on the first call and appends on
subsequent calls. If a scalar already exists at the key, it's
converted to a [scalar, new_value] array - push is forgiving
about prior adds on the same key.
Context::hidden_add - write to the hidden bag
hidden_add;
hidden_add;
// Visible bag dump (e.g. a JSON log emitter) doesn't see them:
let all = all;
assert!;
// But you can still read them deliberately:
let key: = hidden_get;
The hidden bag is keyed independently from the visible bag - a
hidden_add("user_id", 99) and an add("user_id", "alice") coexist
without collision. Context::forget(key) removes from both bags in
one call.
Reading values
Context::get - typed read from the visible bag
use Context;
let user_id: = get;
let tenant: = get;
let trail: = get;
get is generic over T: DeserializeOwned. The stored JSON value
is deserialised on every read. Returns None when:
- The key isn't set
- No scope is active on the current task
- The stored value doesn't deserialise to
T(e.g. you stored ani64and asked for aString)
The last case emits a tracing::trace! so the wrong-type bug is
observable - Context::get looking like "the value isn't set" when
it's really "the value is the wrong shape" is the kind of bug that
costs an hour to find without a log line pointing at it.
Context::hidden_get - typed read from the hidden bag
Same shape as get, reads the hidden bag. Same wrong-type tracing
behaviour.
Context::has - existence check on the visible bag
if has
has only checks the visible bag (use hidden_get(...).is_some()
if you need to probe the hidden bag).
Context::all - snapshot of the visible bag
let snapshot: = all;
Returns an empty HashMap outside a scope. This is what a JSON log
emitter should call to inject request-scoped fields into every log
line - and why the hidden bag exists separately.
Context::forget - remove a key from both bags
forget; // removes from visible AND hidden
The dual-bag removal is intentional. If you stored related data in
both bags (e.g. user_id visible, user_email hidden), one
forget cleans up both.
Reading query parameters
Context::query_param reads from the URL's ?key=value pairs
captured at request entry. The request middleware parses the query
string once into the scope's query bag, then every downstream caller
can read individual params by name without re-parsing:
use Context;
let page: = query_param;
let cursor: = query_param;
let sort: = query_param;
Returns None when the parameter is missing or no scope is active.
Duplicate keys follow Laravel's last-wins semantics - the same value
you'd get from the request's parsed query map.
Pagination reads query params
This is why the query bag exists. Eloquent's paginators read ?page=
and ?cursor= straight off Context::query_param, so a handler
that returns a paginator doesn't need to plumb the page number
through manually:
use ;
use cratePost;
pub async
Three paginator entry points use this:
Builder::paginate(per_page)- reads?page=Builder::simple_paginate(per_page)- reads?page=Builder::cursor_paginate(per_page)- reads?cursor=
See Pagination for the full surface.
Propagating into spawned tasks
tokio::spawn starts the child task with a fresh task-local
environment - the parent's Context scope does not flow in. A
bare tokio::spawn inside a request sees an empty Context and
every read returns None.
To carry the scope into a spawn, snapshot it with Context::current()
and re-enter it inside the child with Context::scope:
use Context;
// Inside a request handler:
if let Some = current
The store returned by Context::current() shares the parent's
underlying maps via Arc - writes from the child are visible to the
parent for as long as the child holds the clone. This is exactly
what audit and logging spawns want: the child can stamp additional
keys (Context::add("audit.completed", true)) and the parent's
final log line sees them.
If you need an isolated snapshot (the child's writes shouldn't leak
back), build a fresh ContextStore and copy in just the keys you
need.
Why bare spawn doesn't propagate
Tokio's task-locals (tokio::task_local!) are intentionally
task-scoped. Auto-inheriting across spawns would mean:
- Long-lived background tasks would pin parent context maps forever
- A panic in a child task could poison the parent's state
- The runtime would have to walk a parent pointer chain on every task-local read
The explicit Context::current() + Context::scope dance makes
propagation a deliberate decision instead of a hidden default.
Queued work
A job runs after the request that queued it, often in another process.
Suprnova carries the Context across that gap for you. A push takes a
ContextSnapshot of the visible and hidden bags, the queue stores it on the
envelope, and the worker runs the job inside a scope restored from it:
use ;
// In a handler:
add;
hidden_add;
push.await?;
// In SendInvoice::handle, on a worker:
let tenant: = get;
let key: = hidden_get;
The same holds for Mail::queue, Mail::later, Notify::queue and a queued
event listener. A chain and a batch give every job the snapshot of the code
that dispatched it.
Six rules describe what the job sees:
- The job works on a copy. What it adds reaches neither the request nor the next job. Every attempt of a job, and every retry, starts from the snapshot again.
- The snapshot is taken at the push. A push that waits for the commit of a
DB::transactioncarries the snapshot of the code that called it. - The query bag does not travel. It describes the request that was being
served, and the job is not that request.
Context::query_paramreturnsNonein a job, including one that runs inline under thesyncdriver. - The request id always travels. The request middleware adds
_request_idto the visible bag, so a job queued while a request is served always carries it. Code outside a request, such as a console command or a scheduled task, carries only what it added itself. A push with nothing to carry writes no context. - Hidden values travel. The job needs them, as it needs the rest of its
payload. They reach the queue store and the failed-job store, so keep
secrets out of the hidden bag unless the job needs them. They never reach a
log: the envelope a worker logs when no failed-job store is bound has no
hidden context, and the
Debugoutput of aContextSnapshotor aContextStorelists hidden keys and never their values. - The lifecycle events run in the job's context.
JobProcessing,JobProcessed,JobFailedand the other worker events are dispatched in the same scope, so a listener reads what the job read and what the job added.
Dehydrating and hydrated hooks
Two hooks run at the two ends of the trip. Register them once, at boot,
in bootstrap::register():
use Context;
// Runs on every snapshot taken for queued work, before it is stored.
dehydrating;
// Runs when a worker has restored a snapshot, before the job runs.
hydrated;
A dehydrating callback receives &mut ContextSnapshot and may add, change or
remove entries. Use it to carry a value that lives outside the context, such
as the request's locale, or to keep a value from leaving the process. It
changes the snapshot only, never the live context.
A hydrated callback receives &ContextSnapshot and runs inside the restored
scope, so the Context methods read and write the job's context. It runs once
for every attempt of a job.
ContextSnapshot has two public fields, data and hidden, both
BTreeMap<String, serde_json::Value>.
The functions behind the hooks
You seldom call these yourself, but a custom worker or a test may need them:
| Function | Purpose |
|---|---|
Context::dehydrate() |
Snapshot the current context, after every dehydrating callback has run. Returns None when there is nothing to carry. |
Context::hydrate(Option<&ContextSnapshot>) |
Build the ContextStore that queued work runs in, and run every hydrated callback. Enter the store with Context::scope. |
Context::restored(Option<ContextSnapshot>, fut) |
Run fut inside a fresh scope that holds the snapshot: hydrate, then scope. |
The hooks are process-wide. A test that registers one removes it again with
Context::test_clear_hooks(), which removes every dehydrating and
hydrated callback. It is compiled under cfg(test) and the testing
feature.
Tests
Inside #[tokio::test] or #[suprnova_test], no Context scope is
installed by default. Most context-touching code under test handles
the "no scope" case gracefully (silent no-op + None reads), so
plain unit tests don't need any setup.
Two situations where the test needs help:
When the code under test calls query_param
The pagination helpers read ?page= via Context::query_param. A
unit test for "page 3 returns the right offset" needs query_param
to return Some("3"). Two ways:
test_query_guard (recommended):
use Context;
async
// `_q` drops at end of scope - thread-local override is wiped.
test_query_guard returns an RAII guard. Even if the test body
panics, Drop runs and clears the thread-local override before the
OS thread is recycled. The guard is #[must_use] - binding it to
_ clears immediately, which is almost never what you want.
Bare test_set_query + test_clear_query:
async
Use the guard form. The manual pair exists for cases where you need
multiple overrides set and cleared independently, but the
#[must_use] guard is harder to misuse.
Both APIs are gated by #[cfg(any(test, feature = "testing"))] -
they're compiled into test binaries and into release builds that
opt into the testing feature for integration test harnesses. They
do not exist in plain release builds.
When the code under test reads or writes from a Context scope
Install one explicitly via Context::scope:
use ;
async
Or seed a query bag at scope creation:
use HashMap;
use ;
async
ContextStore::with_query(HashMap) is the same constructor the
request middleware uses, so a test exercising the same code path as
production sees the same shape of query bag.
Why the thread-local override exists
The query-param override is a thread_local!, not a task-local.
That's deliberate: it lets tests install query params without
wrapping every assertion in a Context::scope call. The
combination is:
- Reads check the thread-local override first
- If no override, read the task-local
CONTEXTscope's query bag - If no scope either, return
None
The thread-local lookup costs effectively nothing in production
(the override is always empty outside test builds) and saves test
authors from boilerplate Context::scope(...) wrappers around every
paginate-related assertion.
Common patterns
Stamp the request id on every log
The framework already does this. The request middleware seeds
_request_id into the visible bag so downstream jobs, broadcasts,
and Context::all() log dumps can read the id by name. The same
middleware also opens a tracing span carrying the id as a span
field, which is what makes it show up on every log line emitted
inside the request - see Logging for the subscriber
side. Reading the id from Context is the right path when you need
the value as a string (for example to plumb into an outbound HTTP
request as a correlation header):
let request_id: = get;
Carry tenant context into a queued job
The tenant id you add in a handler is there when the job runs. Add it once, and push the job:
use ;
// In a handler, or in the middleware that resolves the tenant:
add;
push.await?;
SendInvoice::handle reads Context::get::<String>("tenant_id"), and so does
any logging or deeply-nested helper the job calls. The job needs no field for
the tenant and no scope of its own, because the worker installed one from the
snapshot.
This is also where hidden_add earns its keep - add an API key to the hidden
bag once in the handler, and every downstream HTTP call inside the job reads it
via Context::hidden_get. Remember that hidden values are stored with the
job, see Queued work. See Queues
for the queue side.
Audit trail across a request
push;
// … more work …
push;
// … more work …
push;
// At response-time middleware:
let steps: = get.unwrap_or_default;
info!;
A response-time middleware that runs after the handler can dump the audit trail in one log line, instead of every step's individual debug line scattered across the request log.
Hidden bag for SDK extension credentials
// At request entry, after auth:
hidden_add;
// Deep inside an SDK call:
let key =
.ok_or_else?;
Logs that dump Context::all() don't show the key. The hidden bag
is the right place for any credential the handler needs to pass
deep into a call stack without exposing it to log surfaces.
Why Suprnova diverges
Laravel's Context facade (introduced in Laravel 11) is the
inspiration - same method names, same visible/hidden split, same
"silent outside a request" contract. Two differences come from
Rust's runtime:
Across the queue the context travels, across tokio::spawn it does
not. Laravel's Context flows through queued jobs because Laravel
serialises the context bag into the job payload at dispatch time.
Suprnova does the same at the queue boundary: the envelope carries a
ContextSnapshot, and the worker restores it, with Context::dehydrating
and Context::hydrated as the hooks Laravel calls by the same names. A
task you start with tokio::spawn is different. Rust's async model has no
single "current request" that task-locals flow into, so tokio::spawn
starts fresh. Suprnova exposes the propagation primitive
(Context::current() + Context::scope) and lets you opt into it there,
instead of pretending tasks inherit context they don't.
Wrong-type reads are observable. get::<T> on a value stored
as a different type silently returns None in Laravel (it's PHP,
the types weren't enforced at write time anyway). In Suprnova the
read emits a tracing::trace! because the wrong-type case
indicates a real bug - the value was written somewhere, just not
with the type you're reading. The trace lets you find it in
instrumented runs without changing the no-panic contract.
The third divergence is mechanical: Suprnova's Context is built on
tokio::task_local!, so its lifetime is bound to the Tokio task,
not to any global state. Cross-thread reads see the scope of the
task currently running on that thread, not whatever scope was
installed last. This is what makes the same Context facade safe
to call from a thread pool, an actor, or a spawn_blocking body -
provided you propagate the scope into the spawn.
Where it lives
| Topic | File |
|---|---|
Context facade, ContextStore and ContextSnapshot |
framework/src/context/mod.rs |
| Scope installation on HTTP request | framework/src/logging/request_id.rs |
| Snapshot on push, restore on the worker | framework/src/queue/mod.rs, framework/src/queue/worker.rs |
Context::query_param callers (pagination) |
framework/src/eloquent/builder.rs |
| Re-exports | framework/src/lib.rs (pub use context::{Context, ContextSnapshot, ContextStore}) |
Next
- Request Lifecycle - where the
Contextscope is installed on every request - Service Container - for cross-request state that outlives a single task
- Queues - how a job receives the context of the code that queued it
- Logging - how
Context::all()ends up in structured log lines - Pagination - the main downstream reader of
Context::query_param - Testing -
test_query_guardandContext::scopepatterns for unit tests
