Manual contentsThe BasicsBrowse 103 chapters
Manual 16 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. 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:
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.

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

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

// In a handler:
let tenant_id: String = Context::get("tenant_id")
    .ok_or_else(|| FrameworkError::param("tenant_id missing"))?;

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

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:

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