Getting Started
Configure @adonis-agora/context, read the traceId anywhere, and populate the user and tenant from your auth layer.
Getting @adonis-agora/context running is two steps: configure the package once, then start reading the context anywhere in your app. From there, your auth layer drops the current user and tenant into the store so the rest of your code — and the rest of the ecosystem — can read them.
Prerequisites
- Node.js 20.6+ (the store is built on
node:async_hooks) - AdonisJS 7 (
@adonisjs/core^7.3.0is the only peer dependency) - TypeScript 5+
Step 1 — Install and configure
The package ships an ace configure hook, so the recommended flow is a single command:
node ace add @adonis-agora/contextadd installs the package with your detected package manager and then runs node ace configure @adonis-agora/context for you.
configure runs three codemods so there is zero manual wiring:
- registers the service provider (
@adonis-agora/context/context_provider) inadonisrc.ts; - registers the context middleware (
@adonis-agora/context/context_middleware) on theservermiddleware stack; - publishes
config/context.tsfrom a stub.
The middleware lands on the server stack (not the router stack) on purpose: the server stack runs on every request, before route resolution, so the context exists for everything downstream — including other server middleware. See the enterWith section below.
The published config is all-optional and commented out — the defaults work out of the box:
import { defineConfig } from '@adonis-agora/context'
export default defineConfig({
// traceHeader: 'traceparent',
// initialize: (ctx) => ({ tenantId: ctx.request.header('x-tenant-id') }),
// carrier: ['traceId', 'tenantId', 'userRef'],
})Every key on defineConfig is covered in Customization.
The package also exports VERSION, the release you actually have installed — handy when you stamp build metadata onto a log line or a health endpoint, or when you need to say precisely which version a bug report is about:
import { VERSION } from '@adonis-agora/context'
logger.info({ context: VERSION }, 'boot')Step 2 — Read the context anywhere
Context is a plain singleton — import it and call its accessors. No injection, no app.container.make required, which is the whole point: it works in services, middleware, Lucid hooks, event listeners, and plain functions alike.
import { Context } from '@adonis-agora/context'
Context.traceId() // string | undefined — the request correlation id
Context.tenantId() // string | undefined — the active tenant
Context.userRef() // { type, id } | undefined — the acting principal
Context.get() // the whole ContextStore | undefinedEvery accessor returns undefined when called outside any context (for example during app boot, before any request) — they never throw. A logging hook that stamps the trace id on each line looks like this:
import logger from '@adonisjs/core/services/logger'
import { Context } from '@adonis-agora/context'
export class OrderService {
async place(order: Order) {
logger.info({ traceId: Context.traceId(), tenant: Context.tenantId() }, 'placing order')
// ...
}
}Step 3 — Populate the user and tenant
The middleware establishes the context and the trace id, but it deliberately does not know who the user is — authentication is not this library's job. Your auth layer resolves the principal and writes it into the active store with Context.set(). A natural home for that is a middleware that runs after auth:
import type { HttpContext } from '@adonisjs/core/http'
import type { NextFn } from '@adonisjs/core/types/http'
import { Context } from '@adonis-agora/context'
export default class StampContextMiddleware {
async handle(ctx: HttpContext, next: NextFn) {
const user = ctx.auth.user
if (user) {
Context.set('userRef', { type: 'user', id: user.id })
Context.set('tenantId', user.tenantId)
}
return next()
}
}Context.set(key, value) mutates the store the server middleware already entered. It is fully typed against ContextStore, so Context.set('userRef', …) only accepts a UserRef.
set() outside a context is a no-op
If you call Context.set() where no context is active — for example on a route the server middleware somehow didn't cover, or before any request — the value is silently dropped (it never throws, for backward compatibility) and a one-shot console.warn fires to flag the footgun. If you see that warning, make sure the context is established first (the server middleware, a Context.run, or a Context.enterWith). The warning fires once per process; Context.resetSetWarning() re-arms it (mainly for tests).
Why does a later middleware set the user instead of the context middleware itself? Because authentication runs after the request context exists. The server middleware enters the store at the very top of the pipeline; your auth-aware middleware fills in the principal once it has been verified. This split is intentional — see The Store for why the store carries a userRef rather than the full user object.
From this point on, anything downstream — services, Lucid hooks, the authz and filter libraries — can read Context.userRef() and Context.tenantId() with zero wiring.
Diagnostics events get the trace id for free
If @adonis-agora/diagnostics is installed alongside this package, there is no third step: every agora:* diagnostic event and span already carries the current request's traceId. You write no glue, register no hook, and touch no config — installing both packages is the entire integration.
That covers events you never emit yourself. An authz decision, a durable step transition, a resilience circuit trip, an inertia render — each one arrives at your subscriber stamped with the trace id of the request that caused it:
import { onDiagnostic } from '@adonis-agora/diagnostics'
import logger from '@adonisjs/core/services/logger'
onDiagnostic('authz', (event) => {
// `event.traceId` is the trace id of the request that triggered the check —
// filled in by the context package, not by this listener.
logger.info({ traceId: event.traceId, event: event.event }, event.payload)
})The payoff is that a single trace id joins your log lines to every library's events. Grep one id and you get the request, the authorization decision it made, the workflow it started, and the retry that fired three seconds later — across libraries that know nothing about each other.
The behaviour around the edges is deliberately quiet:
- An explicit trace id wins. Passing
traceIdat emit time overrides the ambient one, so a relayed or replayed event can carry the id it belongs to rather than the one that happened to be active. - Outside a context, the field is simply absent. Events emitted during boot, or from a worker that never entered a store, carry no
traceId— nothing throws and nothing is logged about it. - Either package alone still works. Without diagnostics there is nothing to correlate; without this package diagnostics keeps emitting, with
traceIdleft undefined. Neither one fails because the other is missing.
To get the same correlation outside HTTP, give the worker or command a context first (see Customization → non-HTTP entrypoints) — every event emitted inside that store picks the trace id up the same way.
Why enterWith, not run
This is the one design choice worth understanding, because it explains how a middleware can establish a context that survives into your async handlers.
ALS gives you two ways to set a store:
Context.run(store, fn)runsfnwithstoreactive, and tears the store down the momentfnreturns. It is callback-scoped: everything that should see the context must happen insidefn.Context.enterWith(store)setsstoreas active for the current async execution and all its descendants, with no callback to wrap. The store persists after the call returns.
An AdonisJS middleware calls next() and then returns. If it used run(), the context would be torn down the instant the middleware function exited — long before your async controller method or downstream middleware ever ran. So the built-in middleware uses enterWith: it enters the store and returns, and because the rest of the pipeline executes within the same async context tree, the controller, middleware, and handlers all see it.
// Conceptually, this is what the built-in ContextMiddleware does:
Context.enterWith({ traceId, requestId })
return next() // returns — but the context is still active for everything downstreamUse Context.run() when you own a clean callback boundary and want the context to disappear when the work finishes — for example, wrapping a single queue job or ace command. Use Context.enterWith() when there is no wrapping callback, as in middleware. See Cross-Process for run-based patterns.
Next steps
- The Store — the
ContextStoreshape and how to add your own typed fields - Cross-Process — carry the context across queue and durable boundaries
- Customization — the five levels of customization, from custom fields to swapping the accessor
- Testing — run unit tests inside a fake store
Context
A module-level AsyncLocalStorage for AdonisJS that carries user, tenant and traceId across every boundary — HTTP, queue, durable and ace.
The Store
The ContextStore shape, why it carries a UserRef instead of the full user, the always-present traceId invariant, and how to add your own typed fields.