Contextual logging
Threading tenantId through every function call by hand is the kind of work that quietly disappears as soon as someone forgets it on a new code path. Lasagna binds the active tenant to Node's AsyncLocalStorage so the tag rides on the call stack instead — log lines, audit rows, and downstream services see the tenant id without you passing it.
The two primitives
TenantLogContext owns the AsyncLocalStorage; tenancy.run() is the public entry point that activates a context outside HTTP:
import { tenancy } from '@adonisjs-lasagna/saas-tenancy'
await tenancy.run(tenant, async () => {
// tenancy.currentId() === tenant.id
// tenantLogger() emits { tenantId } on every line
// Lucid models extending TenantBaseModel route to this tenant's schema
// Any async continuation (setTimeout, await fetch, Promise.all) sees the same context
})Inside an HTTP request, TenantGuardMiddleware already wraps the handler in tenancy.run(...) for you — every controller, service, and Lucid call inherits the binding without ceremony. Outside HTTP (queue jobs, scheduled commands, REPL scripts), you wrap the entry point yourself.
tenantLogger()
A thin async wrapper that returns the AdonisJS root logger with the active tenant context bound to it:
import { tenantLogger } from '@adonisjs-lasagna/saas-tenancy/services'
const log = await tenantLogger()
log.info({ orderId: order.id }, 'Order created')
// → { tenantId: '...', orderId: '...', msg: 'Order created' }Outside any tenancy.run() scope, tenantLogger() returns the plain root logger. There's no penalty for calling it everywhere — if no context is active, no extra fields are attached.
The wrapper uses Pino's native child(bindings) API, so the serializer chain you've configured for the root logger continues to apply. No Lasagna-specific format leaks into your dashboards.
Adding extra context fields
TenantLogContext.run() accepts arbitrary key/value pairs alongside tenantId:
import app from '@adonisjs/core/services/app'
import { TenantLogContext } from '@adonisjs-lasagna/saas-tenancy/services'
const ctx = await app.container.make(TenantLogContext)
await ctx.run({ tenantId, requestId, traceId }, async () => {
// every log line within this scope carries all three fields
})Stick to fields that are safe to log: don't put PII or secrets here because they'll show up in every log line.
Inside queue jobs
Each of the five built-in jobs already wraps its execute() in tenancy.run(). If you write your own tenant-aware job, do the same:
import { Job } from '@adonisjs/queue'
import { tenancy } from '@adonisjs-lasagna/saas-tenancy'
export default class GenerateInvoice extends Job<{ tenantId: string; invoiceId: string }> {
async execute() {
const tenant = await loadTenant(this.payload.tenantId)
return tenancy.run(tenant, async () => {
const log = await tenantLogger()
log.info({ invoiceId: this.payload.invoiceId }, 'Generating invoice')
await renderPdf()
await uploadToStorage()
})
}
}The integration suite proves the propagation works under contention with 3 tenants × 30 jobs interleaved randomly: tests/integration/jobs/tenant_context.spec.ts.
Reading the active tenant
Three flavors, all exported from the package root:
import { tenancy } from '@adonisjs-lasagna/saas-tenancy'
tenancy.currentId() // string | undefined — synchronous, cheap
await tenancy.current() // TenantModelContract | null — hits the repositoryFor one-off log enrichment, use currentId() — synchronous, no container resolution, no DB call:
const tenantId = tenancy.currentId()
if (tenantId) span.setAttribute('tenant.id', tenantId)For full tenant data in scheduled jobs or background services, use tenancy.current(). It uses findById(id, true) so soft-deleted tenants resolve too — useful in cleanup or audit code.
Nested scopes
tenancy.run() and TenantLogContext.run() honor a stack: an inner scope shadows the outer scope while it's active, then the outer is restored on return. This makes batch processing across tenants clean:
for (const tenant of allTenants) {
await tenancy.run(tenant, () => processOne(tenant))
}Each iteration sees its own context, and nothing leaks into the next.
Bootstrappers reuse the same hook
Every tenant bootstrapper (cache, drive, mail, session, broadcasting) implements enter(ctx) / leave(ctx) and is invoked by tenancy.run() via the BootstrapperRegistry. So inside a tenancy.run() scope, the cache namespace, mailer, drive prefix, and session keys are all already pointing at the active tenant — without extra wiring. See the Bootstrappers overview.
Testing
TenantLogContext has zero dependencies, so unit tests can drive it directly:
import TenantLogContext from '@adonisjs-lasagna/saas-tenancy/services'
test('audit row carries the active tenant', async ({ assert }) => {
const ctx = new TenantLogContext()
await ctx.run({ tenantId: 'fake-id' }, async () => {
assert.equal(ctx.currentTenantId(), 'fake-id')
})
})The full unit suite at tests/unit/services/tenant_log_context.spec.ts covers parallel scopes, nested run, async continuation propagation, and the no-bleed guarantee at scope exit.
Related
- Jobs — every built-in job wraps work in
tenancy.run() - Concepts — how AsyncLocalStorage interacts with per-tenant Lucid connections
- Lifecycle events — listeners run inside the dispatching scope, so
tenancy.currentId()is available without extra plumbing