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. 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. Inside a handler, middleware, model observer, event
listener, or anything else reachable from the request task, 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.
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
Context doesn't auto-propagate across the queue serialise /
deserialise boundary - the worker runs in a different process from
the dispatcher, often on a different machine. Pass anything you
need into the job's payload:
use ;
// In a handler:
let tenant_id: String = get
.ok_or_else?;
push.await?;
When the worker processes SendInvoice, install a fresh Context
scope at the top of Job::handle and re-seed the keys you need from
the job payload - Context::scope(ContextStore::default(), async { ... }) wrapping the body. Then any logging or deeply-nested helper
the job calls sees the same tenant id it would inside a request.
This is also where hidden_add earns its keep - the job can fetch
and stash an API key once at scope entry, and every downstream HTTP
call inside the job reads it via Context::hidden_get without
re-fetching. See Queues for the Job trait shape.
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:
Async propagation is explicit, not magical. Laravel's Context
flows through queued jobs automatically because Laravel serialises
the context bag into the job payload at dispatch time. Rust's
async model doesn't have a single "current request" Thread-Locals
flow into - tokio::spawn starts fresh, and the queue boundary
involves serialisation across processes. Suprnova exposes the
propagation primitive (Context::current() + Context::scope) and
lets you opt into it at the boundary, 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 |
framework/src/context/mod.rs |
| Scope installation on HTTP request | framework/src/logging/request_id.rs |
Context::query_param callers (pagination) |
framework/src/eloquent/builder.rs |
| Re-exports | framework/src/lib.rs (pub use context::{Context, ContextStore}) |
Next
- Request Lifecycle - where the
Contextscope is installed on every request - Service Container - for cross-request state that outlives a single task
- 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
