Manual contentsThe BasicsBrowse 113 chapters
Manual 20 min read

Context

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 suprnova::Context;

Context::add("tenant_id", "acme");
Context::push("breadcrumbs", "checkout/start");
Context::hidden_add("api_key", secret);

let tenant: Option<String> = Context::get("tenant_id");
let page: Option<String> = Context::query_param("page");

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:
Context::add("user_id", 42i64);
let id: Option<i64> = Context::get("user_id");
assert_eq!(id, Some(42));

// Outside a scope - silent no-op + None:
Context::add("user_id", 42i64);            // discarded
let id: Option<i64> = Context::get("user_id");
assert_eq!(id, None);

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 suprnova::Context;

Context::add("user_id", 42i64);
Context::add("tenant", "acme");
Context::add("plan", PlanTier::Pro);     // 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

Context::push("trail", "home");
Context::push("trail", "settings");
Context::push("trail", "billing");

let trail: Vec<String> = Context::get("trail").unwrap();
assert_eq!(trail, vec!["home", "settings", "billing"]);

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

Context::hidden_add("api_key", os_env_secret);
Context::hidden_add("oauth_bearer", token);

// Visible bag dump (e.g. a JSON log emitter) doesn't see them:
let all = Context::all();
assert!(!all.contains_key("api_key"));

// But you can still read them deliberately:
let key: Option<String> = Context::hidden_get("api_key");

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 suprnova::Context;

let user_id: Option<i64>       = Context::get("user_id");
let tenant:  Option<String>    = Context::get("tenant");
let trail:   Option<Vec<String>> = Context::get("trail");

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 an i64 and asked for a String)

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 Context::has("user_id") {
    // …
}

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: HashMap<String, serde_json::Value> = Context::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

Context::forget("trail");          // 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 suprnova::Context;

let page: Option<String>   = Context::query_param("page");
let cursor: Option<String> = Context::query_param("cursor");
let sort: Option<String>   = Context::query_param("sort");

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 suprnova::{json_response, Request, Response};
use crate::models::Post;

pub async fn index(_req: Request) -> Response {
    // Reads ?page=N from the request's URL via Context::query_param
    // - no req.query() boilerplate, no parameter threading.
    let posts = Post::query()
        .order_by_desc("created_at")
        .paginate(15)
        .await?;

    json_response!(posts)
}

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 suprnova::context::Context;

// Inside a request handler:
if let Some(store) = Context::current() {
    tokio::spawn(Context::scope(store, async move {
        // Now `Context::get`, `Context::query_param`, etc. see the
        // parent request's bag.
        let request_id: Option<String> = Context::get("_request_id");
        do_background_work(request_id).await;
    }));
}

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 suprnova::{Context, Queue};

// In a handler:
Context::add("tenant_id", "acme");
Context::hidden_add("api_key", token);
Queue::push(SendInvoice { invoice_id: 7 }).await?;

// In SendInvoice::handle, on a worker:
let tenant: Option<String> = Context::get("tenant_id");
let key: Option<String> = Context::hidden_get("api_key");

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::transaction carries 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_param returns None in a job, including one that runs inline under the sync driver.
  • The request id always travels. The request middleware adds _request_id to 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 Debug output of a ContextSnapshot or a ContextStore lists hidden keys and never their values.
  • The lifecycle events run in the job's context. JobProcessing, JobProcessed, JobFailed and 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 suprnova::Context;

// Runs on every snapshot taken for queued work, before it is stored.
Context::dehydrating(|snapshot| {
    snapshot.data.insert("locale".into(), serde_json::json!("fr"));
    snapshot.hidden.remove("session_token");
});

// Runs when a worker has restored a snapshot, before the job runs.
Context::hydrated(|snapshot| {
    if let Some(locale) = snapshot.data.get("locale") {
        Context::add("locale_restored", locale.clone());
    }
});

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 suprnova::Context;

#[tokio::test]
async fn paginate_reads_page_from_query() {
    let _q = Context::test_query_guard("page", "3");

    // Code under test now sees ?page=3
    assert_eq!(Context::query_param("page"), Some("3".into()));

    let posts = Post::query().paginate(15).await?;
    assert_eq!(posts.current_page(), 3);
}
// `_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:

#[tokio::test]
async fn manual_pair() {
    Context::test_clear_query();        // wipe leak from any sibling
    Context::test_set_query("page", "5");

    // … assertions …

    Context::test_clear_query();
}

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 suprnova::context::{Context, ContextStore};

#[tokio::test]
async fn handler_reads_tenant_id() {
    Context::scope(ContextStore::default(), async {
        Context::add("tenant_id", "acme");

        let resolved = my_helper_that_reads_tenant().await;
        assert_eq!(resolved, "acme");
    })
    .await;
}

Or seed a query bag at scope creation:

use std::collections::HashMap;
use suprnova::context::{Context, ContextStore};

#[tokio::test]
async fn handler_reads_query_from_scope() {
    let mut q = HashMap::new();
    q.insert("page".into(), "3".into());
    q.insert("sort".into(), "name".into());

    Context::scope(ContextStore::with_query(q), async {
        assert_eq!(Context::query_param("page"), Some("3".into()));
        assert_eq!(Context::query_param("sort"), Some("name".into()));
    })
    .await;
}

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:

  1. Reads check the thread-local override first
  2. If no override, read the task-local CONTEXT scope's query bag
  3. 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: Option<String> = Context::get("_request_id");

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 suprnova::{Context, Queue};

// In a handler, or in the middleware that resolves the tenant:
Context::add("tenant_id", "acme");

Queue::push(SendInvoice { invoice_id }).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

Context::push("audit.steps", "validated_input");
// … more work …
Context::push("audit.steps", "charged_card");
// … more work …
Context::push("audit.steps", "sent_receipt");

// At response-time middleware:
let steps: Vec<String> = Context::get("audit.steps").unwrap_or_default();
tracing::info!(?steps, "request audit trail");

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:
Context::hidden_add("sdk.api_key", load_api_key_for(user_id));

// Deep inside an SDK call:
let key = Context::hidden_get::<String>("sdk.api_key")
    .ok_or_else(|| FrameworkError::param("api key not stashed"))?;

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 Context scope 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_guard and Context::scope patterns for unit tests