Customization
The five levels of customizing @adonis-agora/context — custom fields, populating values, non-HTTP entrypoints, the cross-process carrier, and swapping the accessor.
@adonis-agora/context is plug-and-play with sane defaults — node ace configure plus an empty config/context.ts works for most HTTP apps. But every layer is customizable, and the customization surface is organized as five levels, ordered from the most common to the most advanced. Nothing here is mandatory; reach for a level only when the default no longer fits.
| Level | What you change | How (AdonisJS) | Default |
|---|---|---|---|
| 1 | Add your own fields | module augmentation | — |
| 2 | Populate fields / trace id | traceId / initialize / enrichers in defineConfig | traceparent header → random |
| 3 | Non-HTTP entrypoints | don't plug the middleware + Context.run / enterWith | middleware on server stack |
| 4 | What survives cross-process | carrier / serialize / baggage in defineConfig | traceId + tenantId + userRef |
| 5 | Swap the accessor libs read | override the globalThis accessor slot | default accessor |
Everything in levels 2 and 4 lives in the single config/context.ts file, validated by the exported defineConfig helper.
Level 1 — Custom fields
Add typed fields to the store with module augmentation. This is covered in detail on The Store; the short version:
import '@adonis-agora/context'
declare module '@adonis-agora/context' {
interface ContextStore {
locale?: string
impersonatorId?: string
}
}Augmentation only declares the field. Populating it is Level 2.
Level 2 — Populate fields and override the trace id
config/context.ts accepts hooks that the middleware runs at the start of every request. They let you control how the trace id is produced and pre-fill any store field (including your Level 1 custom fields). Every hook receives the AdonisJS HttpContext:
import { defineConfig, randomTraceId } from '@adonis-agora/context'
export default defineConfig({
// Override how the trace id is produced for a request.
traceId: (ctx) => ctx.request.header('x-correlation-id') ?? randomTraceId(),
// Merge extra fields into the initial store at request start.
initialize: (ctx) => ({
locale: ctx.request.header('accept-language'),
tenantId: ctx.subdomains?.tenant,
}),
})The available population options:
traceHeader?: string— the header to read the inbound trace id from. Defaults to the W3Ctraceparent. When the header is absent or malformed, a fresh trace id is generated.traceId?: (ctx) => string— a full override of trace-id production. When provided, its return value wins overtraceHeaderand the random fallback.initialize?: (ctx) => Partial<ContextStore>— a bag of fields merged into the initial store. Ideal for pre-populatingtenantIdand your custom fields.enrichers?: ContextEnricher[]— derived-field functions, see below.
userRef still typically enters later, via Context.set() after authentication, because auth runs after the context is established (see Getting Started).
Precedence — the resolved traceId and requestId always win
The middleware applies values in a specific order, and the order matters:
- It computes the trace id (from your
traceIdhook → thetraceHeader→ a random fallback) and readsrequestIdfromctx.request.id(). - It merges the
initialize(ctx)bag into the store first. - It writes the dedicated
traceIdandrequestIdlast.
The consequence: the resolved traceId and requestId always win. A stray traceId returned by initialize() cannot clobber the trace id the middleware resolved — the dedicated path is authoritative.
Deriving fields: enrichers and lazy
For values derived from the store (a displayName from tenantId, a region from a header), use one of two mechanisms depending on cost:
-
Eager —
enrichers. An array of(store, req?: unknown) => Partial<ContextStore> | voidfunctions the middleware runs right after entering the context (and afterinitialize). Each one sees the assembled store and may either return a partial to merge or mutate the store in place — both styles work, and you can mix them in one array. A throwing enricher is isolated: it never breaks the request or the other enrichers.config/context.ts export default defineConfig({ enrichers: [ // Return a partial — it is merged into the store. (store) => ({ region: regionForTenant(store.tenantId) }), // Or write onto the store directly and return nothing. (store) => { store.region = regionForTenant(store.tenantId) }, ], })Because the provider also pushes enrichers onto the singleton, a non-HTTP entrypoint can run them too, after a
Context.run/enterWith, viaContext.runEnrichers(). -
Lazy —
Context.lazy. For values that are expensive or rarely needed, compute on first access and memoize onto the store, so subsequent reads this request are free:const name = Context.lazy('displayName', (s) => lookupName(s.userRef))The factory runs at most once per store per key; if the field is already present, its value is returned and the factory is skipped. Returns
undefinedoutside any context (nowhere to cache).
The second argument is unknown, not HttpContext
Enrichers run on both sides of the HTTP boundary: the middleware passes the AdonisJS HttpContext, while Context.runEnrichers(req) passes whatever a queue worker or an ace command hands it (often nothing at all). The parameter is therefore typed unknown, and reading a header off it without narrowing first is a type error (TS18046: 'req' is of type 'unknown').
Narrow it with an explicit cast on the HTTP side, where you know what the middleware passed:
import type { HttpContext } from '@adonisjs/core/http'
import { defineConfig } from '@adonis-agora/context'
export default defineConfig({
enrichers: [
(store, req) => {
// The middleware passes the HttpContext; a non-HTTP caller may pass
// nothing, hence the `| undefined` in the cast.
const ctx = req as HttpContext | undefined
const region = ctx?.request.header('x-region')
if (region) {
store.region = region
}
},
],
})An enricher written this way stays safe when the same config is used by a worker: ctx is simply undefined there, the header lookup is skipped, and the field stays unset.
Level 3 — Non-HTTP entrypoints
The HTTP middleware is just the default. For a queue worker, an ace command, or any non-HTTP entrypoint, you don't need the middleware at all — you establish the context yourself with the public primitives:
import { Context, randomTraceId } from '@adonis-agora/context'
// e.g. an ace command or a scheduled task
await Context.run(
{ traceId: randomTraceId(), userRef: { type: 'system', id: 'cron' } },
() => doTheWork(),
)Here Context.run is the right tool (not enterWith): you own a clean callback boundary, so the context is established for the duration of the work and torn down cleanly when it finishes. For cross-process jobs there is a dedicated helper, Context.deserialize — see Cross-Process.
Configuring the singleton without the provider
ContextProvider reads config/context.ts at boot and hands the cross-boundary half of it to the singleton for you. A process that never boots the AdonisJS application — a bare worker script, a one-off Node entrypoint, a standalone consumer — has no provider, so the singleton keeps its defaults: the carrier is traceId + tenantId + userRef, baggage uses the plain field names, and no enrichers run. Context.configure() is the way to set it up by hand:
import { Context } from '@adonis-agora/context'
// Once, at process start, before any work runs.
Context.configure({
carrier: ['traceId', 'tenantId', 'userRef', 'locale'],
baggage: { tenantId: 'acme.tenant', userRef: false },
enrichers: [
(store) => {
store.region = regionForTenant(store.tenantId)
},
],
})Two things to keep in mind:
- It reads only the cross-boundary keys —
carrier,serialize,deserialize,baggageandenrichers. The population hooks (traceHeader,traceId,initialize) are the HTTP middleware's, and passing them here has no effect; a worker establishes its own store withContext.run/Context.deserializeinstead. - The config is process-global and replaced wholesale. Calling
configurea second time with a different config warns, because the last call wins.Context.resetConfig()puts the singleton back to its defaults — the same reset the testing page recommends between tests.
Because the config file itself is a plain object, a worker that lives in the same repository can simply hand it the app's config instead of duplicating it:
import { Context } from '@adonis-agora/context'
import contextConfig from '#config/context'
Context.configure(contextConfig)Running enrichers outside HTTP
Enrichers are configured centrally but they are run by whoever establishes the context. The middleware does it for HTTP requests; outside HTTP you call Context.runEnrichers() yourself, right after entering the store — so a worker derives exactly the same fields a request would:
import { Context, randomTraceId } from '@adonis-agora/context'
await Context.run({ traceId: randomTraceId(), tenantId: 'acme' }, async () => {
Context.runEnrichers() // derived fields are now on the store
await rebuildReport()
})It behaves the same way the middleware's pass does: enrichers run in order, each one sees what the previous ones wrote, a thrower is isolated from the rest, and returned partials are merged. It is a no-op — never a throw — when there is no active context or no enrichers are configured, so it is safe to call unconditionally. Anything you pass is forwarded as the enricher's second argument:
Context.runEnrichers(job) // arrives as the `req` parameter, typed `unknown`If you want the context on some HTTP routes only, register the middleware on the named middleware stack and apply it per-route instead of the server stack — but the common (and configured-by-default) case is the server stack, so the context exists everywhere. The codemod wires the server stack for you.
Level 4 — Cross-process carrier
When the context crosses a process or queue boundary it is reduced to a carrier — a flat, serializable snapshot. By default the carrier includes traceId, tenantId, and userRef. To carry an additional (Level 1) custom field across the boundary, list the fields explicitly:
export default defineConfig({
carrier: ['traceId', 'tenantId', 'userRef', 'locale'],
})Or take full control of both directions with serialize / deserialize overrides:
export default defineConfig({
serialize: (store) => ({
traceId: store.traceId,
tenantId: store.tenantId,
userRef: store.userRef,
// ...anything else you want on the wire
}),
deserialize: (carrier) => ({
traceId: carrier.traceId,
tenantId: carrier.tenantId,
userRef: carrier.userRef,
}),
})You can also tune W3C baggage propagation here. By default Context.toBaggage() / fromBaggage() map tenantId and userRef; set a custom key to namespace them, or false to never propagate a field:
export default defineConfig({
baggage: {
tenantId: 'acme.tenant', // namespaced key
userRef: false, // never put the principal on a baggage header
},
})The mechanics of serialize() / deserialize(), bind(), and the queue / durable / baggage patterns are covered on Cross-Process. What matters here is a sharp edge in how this config is stored.
The carrier config is process-global
The carrier / serialize / deserialize / baggage / enrichers config does not live in the IoC container — it lives in a module-level singleton, because it has to be readable from places the container cannot reach (queue workers, Lucid hooks, the durable worker, ace commands). The provider reads config/context.ts at boot() and pushes this subset onto the singleton via Context.configure. That makes it process-global: shared across the whole process.
Each Context.configure call replaces the whole carrier config wholesale — it is never merged. This is on purpose: it guarantees you can never end up pairing one app's serialize with another app's deserialize.
Because of that, a second configure with a different config emits a console.warn — the last one wins, which is rarely what you want. In multi-app setups (one process hosting several apps) or in test suites that configure the package repeatedly, call Context.resetConfig() between apps/tests to clear the singleton back to defaults.
import { Context } from '@adonis-agora/context'
// In a test or multi-app harness, between apps:
Context.resetConfig()Level 5 — Swapping the accessor
Consumer libraries (authz, filter, diagnostics) do not import Context directly. Instead, at import time, @adonis-agora/context publishes a read-only accessor on a globalThis slot keyed by Symbol.for('@agora/context:accessor'), exported as CONTEXT_ACCESSOR. Consumers read that slot structurally — they never import this package — and degrade to undefined when it is absent.
The accessor surface is intentionally narrow — consumers read, they do not drive the lifecycle:
interface ContextAccessor {
traceId(): string | undefined
tenantId(): string | undefined
userRef(): UserRef | undefined
// `get` is overloaded — both forms are part of the contract.
get(): ContextStore | undefined
get(key: string): unknown
}`get` is overloaded — implement both forms
The keyed get(key) is not optional sugar. Consumers are split across the two forms: telescope and resilience call get() for the whole store, while authz calls get('globalRoles') to read a single field. An accessor that implements only the no-argument form still satisfies a structural check — the call get('globalRoles') compiles and runs — but the argument is ignored and the whole store comes back where a field was expected. Nothing throws; the consumer just reads a value it cannot make sense of. That is not hypothetical: it is exactly the shape mismatch that made authz resolve [] for every role check, so every permission silently failed closed.
If you replace the accessor, honour both forms: no argument returns the store, a key returns that field off the store.
The default implementation, contextAccessor, is a thin facade over the singleton Context, and it is published on that slot at module load — merely importing @adonis-agora/context (which the provider does) wires it. CONTEXT_ACCESSOR is the symbol, not a container binding.
This is a globalThis slot, not an IoC binding
The accessor does not live in the IoC container. ContextProvider only has a boot() — it pushes cross-boundary config onto the singleton and hands the population hooks to the middleware; it binds nothing, so app.container.swap(CONTEXT_ACCESSOR, …) does nothing. Consumers read globalThis[Symbol.for('@agora/context:accessor')] structurally, and that is exactly where you swap it: overwrite the slot.
To install a custom accessor — for instance one that resolves the full user from the userRef — overwrite the globalThis slot with your own implementation. Do it once, early (a provider boot() is a fine place to run the assignment, but it is a plain global write, not a container call):
import {
CONTEXT_ACCESSOR,
type ContextAccessor,
Context,
} from '@adonis-agora/context'
export default class AppProvider {
constructor(protected app: ApplicationService) {}
async boot() {
const hydrating: ContextAccessor = {
traceId: () => Context.traceId(),
tenantId: () => Context.tenantId(),
userRef: () => Context.userRef(),
// The overloaded `get`: no-arg → the whole store; keyed → a single field.
get: ((key?: string) => {
const store = Context.get()
return key === undefined ? store : store?.[key as keyof typeof store]
}) as ContextAccessor['get'],
// ...plus whatever extra resolution your consumers expect
}
// Overwrite the published slot — this is what every consumer lib reads.
;(globalThis as Record<symbol, unknown>)[CONTEXT_ACCESSOR] = hydrating
}
}Overwriting the slot is primarily an advanced / testing seam. Most consumer libraries read the structural globalThis accessor, so the everyday way to change what they see is the config in levels 1–4, not a slot swap. In tests, prefer the testing helpers (runWithContext / enterContext) — they populate the real store the default accessor reads, so you rarely need to replace the accessor at all. If a test truly must, save and restore the previous slot value in a teardown so it does not leak into the next test.
Writing context from sibling libraries
The accessor above is read-only on purpose. But some sibling libs need to populate the context — for example @adonis-agora/authkit setting the userRef after it authenticates a request. For that, @adonis-agora/context publishes a symmetric write slot on a globalThis slot keyed by Symbol.for('@agora/context:set'), exported as CONTEXT_SET.
Like the accessor, it is wired at import time, so a sibling lib reads it structurally — no import of this package — and degrades to a no-op when it is absent. The slot value is a tiny writer that merges a patch into the currently-active store and is a safe no-op when there is no active context (it never throws):
type ContextSetPatch = { userRef?: UserRef; tenantId?: string; [k: string]: unknown }
// The slot value:
;(patch: ContextSetPatch) => voidA sibling lib consumes it structurally:
const set = (globalThis as any)[Symbol.for('@agora/context:set')] as
| ((patch: { userRef?: { type: string; id: string | number }; tenantId?: string }) => void)
| undefined
// Degrades to a no-op when @adonis-agora/context is not installed.
set?.({ userRef: { type: 'user', id: 42 } })First-party code can import it directly instead:
import { contextWriter } from '@adonis-agora/context'
contextWriter.set({ userRef: { type: 'user', id: 42 }, tenantId: 'acme' })Under the hood the writer just calls Context.set per key, so the active-store lookup and the out-of-context no-op (with the one-shot warning) behave exactly as documented in Level 2.
Restoring a whole store from a sibling lib — contextScope / CONTEXT_SCOPE
CONTEXT_SET only populates an already-active store — it is a no-op where none exists. That is the wrong tool for a worker that starts with no active scope and needs to establish a store from a serialized snapshot before running work. For that, @adonis-agora/context publishes a third globalThis slot keyed by Symbol.for('@agora/context:scope'), exported as CONTEXT_SCOPE, whose value is the contextScope(snapshot, fn) function:
import { contextScope } from '@adonis-agora/context'
// Runs `fn` INSIDE a freshly-entered store seeded from the full snapshot.
// The ENTIRE snapshot survives (including module-augmented keys), and a
// snapshot missing/empty `traceId` is repaired via the same trace-id safety
// net as deserialize(). An `undefined` snapshot runs `fn` with no active store.
const result = contextScope(snapshot, () => doTheWork())This is the scoped counterpart to CONTEXT_SET: where the writer merges a patch into the current store, contextScope creates the store around a callback (it delegates to Context.run). It is precisely the primitive @adonis-agora/durable reads — structurally, no import — to restore the originating request's context on a worker before invoking a remote-step handler (see Cross-Process → durable). A sibling lib consumes it the same structural way, degrading to a plain call when the slot is absent:
const scope = (globalThis as any)[Symbol.for('@agora/context:scope')] as
| (<T>(snapshot: Record<string, unknown> | undefined, fn: () => T) => T)
| undefined
// Degrades to running fn with no context when @adonis-agora/context is absent.
scope ? scope(snapshot, () => run()) : run()The three slots line up as a set: CONTEXT_ACCESSOR reads, CONTEXT_SET writes into an active store, and CONTEXT_SCOPE establishes a store from a snapshot.
Next steps
- Cross-Process — the full story behind
serialize/deserialize,bind, baggage, and the queue / durable patterns - Testing —
Context.resetConfig()and the testing helpers - The Store — the field-level reference for everything you are customizing