Context capture (causer attribution)
Capture who triggered a notification — the causer, tenant, and trace id — at send() time and thread it through lifecycle events, the async carrier, and the database row. Integrates @dudousxd/nestjs-context; degrades to a no-op when it's absent.
A notification always knows who it is going to (the notifiable). Context capture adds who it came
from — the causer (the acting user), the tenant the action happened in, and the request's
trace id — snapshotted at send() time and carried the whole way to delivery. So a persisted
row can answer "Bob archived Alice's document" instead of just "a document was archived", and an
async-delivered notification still records the actor even though the worker runs long after the
request ended.
It's built on @dudousxd/nestjs-context — an
optional peer. When it isn't installed (or no accessor is bound), capture is undefined and every
channel behaves exactly as before. Nothing to opt out of; nothing changes until you wire an accessor.
What gets captured
At the moment you call notifications.send(...), the core reads the current request context into a
plain, JSON-safe CapturedContext:
interface CapturedContext {
/** Who triggered the notification (the current user/actor), if known. */
causer?: { type: string; id: string | number };
/** The tenant the trigger happened in, if a multi-tenant context is populated. */
tenantId?: string;
/** Correlation/trace id of the triggering request, for end-to-end tracing. */
traceId?: string;
}Every field is optional — an absent accessor, or one that returns undefined for a field, simply
omits it. When nothing is captured, the whole object is left off (captured stays undefined).
This capture then rides along three surfaces:
- Lifecycle events —
NotificationSendingEvent,NotificationSentEvent, andNotificationFailedEventeach carry acapturedfield. - The
DeliveryContexthanded to every channel'ssend()— ascontext.captured. - The async carrier — cross-process dispatchers (BullMQ, Redis) serialize
capturedinto the job and re-establish it on the worker, so a background delivery still knows the actor.
The database channel reads it off the DeliveryContext and persists
causerType / causerId / traceId columns on the notification row — attribution that survives
long after the request is gone.
Wire the accessor
Install and register @dudousxd/nestjs-context
pnpm add @dudousxd/nestjs-contextRegister its module so an accessor is populated per request (see the nestjs-context docs for its middleware / interceptor wiring — typically it reads the authenticated user, tenant, and a request id off the incoming request):
import { ContextModule } from '@dudousxd/nestjs-context';
@Module({
imports: [
ContextModule.forRoot({ global: true }),
NotificationsModule.forRoot({ notifications: [DocumentArchived] }),
DatabaseChannelModule.forRoot(),
],
})
export class AppModule {}Bind the accessor under CONTEXT_ACCESSOR
The notifications core looks up its accessor under the shared symbol
CONTEXT_ACCESSOR (Symbol.for('@dudousxd/nestjs-context:accessor')), injected @Optional(). If
your ContextModule already provides that symbol app-wide, you're done — the core resolves it through
a non-strict ModuleRef lookup, so an accessor provided by any module (a global ContextModule, or
the app root) is found.
If you expose the accessor under a different provider, alias it to the shared token so notifications can see it:
import { CONTEXT_ACCESSOR } from '@dudousxd/nestjs-notifications-core';
import { ContextAccessor as NestjsContextAccessor } from '@dudousxd/nestjs-context';
@Module({
providers: [
{ provide: CONTEXT_ACCESSOR, useExisting: NestjsContextAccessor },
],
})
export class AppModule {}The core never imports @dudousxd/nestjs-context — it declares a structural ContextAccessor
interface (traceId(), tenantId(), userRef(), get()) and injects any object that satisfies
it. That keeps the dependency optional and lets you bind a hand-rolled accessor (or a test double)
under the same token.
Bring your own accessor
You don't need @dudousxd/nestjs-context at all — any provider that structurally matches
ContextAccessor works. For example, an accessor backed by your own AsyncLocalStorage:
import { Injectable } from '@nestjs/common';
import { CONTEXT_ACCESSOR, type ContextAccessor, type UserRef } from '@dudousxd/nestjs-notifications-core';
import { requestStore } from './request-store'; // your AsyncLocalStorage<{ user, tenant, traceId }>
@Injectable()
export class RequestContextAccessor implements ContextAccessor {
traceId(): string | undefined {
return requestStore.getStore()?.traceId;
}
tenantId(): string | undefined {
return requestStore.getStore()?.tenant;
}
userRef(): UserRef | undefined {
const user = requestStore.getStore()?.user;
return user ? { type: 'User', id: user.id } : undefined;
}
get() {
return requestStore.getStore();
}
}@Module({
providers: [
RequestContextAccessor,
{ provide: CONTEXT_ACCESSOR, useExisting: RequestContextAccessor },
],
})
export class AppModule {}The core's captureContext(accessor?) helper is defensive: it swallows any throw from the accessor
and returns undefined, so a broken context provider can never break a notification send.
Read the captured causer
On the persisted row
Once the database channel is in play, the causer and trace are columns on every stored notification:
const rows = await this.store.paginateForNotifiable('User', String(user.id), { limit: 20, offset: 0 });
for (const row of rows) {
// row.causerType / row.causerId — WHO triggered it (null on rows sent without a context)
// row.traceId — correlate back to the request logs
console.log(`${row.causerType}#${row.causerId} → ${row.type} (trace ${row.traceId})`);
}StoredNotification gains causerType: string | null, causerId: string | null, and
traceId: string | null. They're null on rows written before you wired an accessor, or on sends
with no active context (a cron job, a startup task), so existing rows and schema-first stores are
unaffected.
The delivery tenant wins for the row's tenantId; when a send is unscoped, the captured
tenant is used as a fallback so the row is still attributable to the right workspace. See
Multi-tenancy.
On lifecycle events
Every notification lifecycle event carries the same capture — handy for audit logging or tracing without touching the database channel:
import { OnEvent } from '@nestjs/event-emitter';
import { NotificationSentEvent, NotificationEvents } from '@dudousxd/nestjs-notifications-core';
@Injectable()
export class NotificationAuditListener {
@OnEvent(NotificationEvents.sent)
onSent(event: NotificationSentEvent) {
const c = event.captured;
if (!c) return; // no context was active for this send
this.audit.record({
causer: c.causer, // { type, id } | undefined
tenantId: c.tenantId,
traceId: c.traceId, // stitch into your tracing backend
notification: event.notification.constructor.name,
});
}
}Inside a channel
Custom channels receive the capture on the DeliveryContext third argument of send() — so a
custom channel can attach the actor to an outbound payload:
async send(notifiable: Notifiable, notification: Notification, context?: DeliveryContext) {
const causer = context?.captured?.causer; // { type, id } | undefined
const traceId = context?.captured?.traceId; // for provider-side correlation
// …forward causer/traceId to your transport
}Survives async dispatch
The whole point of capturing at send time is that the actor outlives the request. When you dispatch
asynchronously, the captured object is serialized into the job
payload and re-established on the worker, so a notification delivered minutes later by a
BullMQ or Redis worker still records who
triggered it — no request context on the worker required. Because CapturedContext is a plain,
JSON-safe object by construction, nothing special is needed to round-trip it.
See also
- Multi-tenancy — how the tenant scope and the captured tenant relate
- Database channel — where the causer/trace columns live
- Async dispatch — how the carrier crosses the process boundary
Multi-tenancy
The same user lives in many workspaces, each with its own feed. Scope any send to one tenant or fan out to many with forTenant — tenant flows into storage, the read API, and per-tenant channel config.
Dispatch guards
Dedup (idempotency) and throttle (rate-limit) a notification before any channel runs. Opt in per notification with idempotencyKey() and throttle(); back them with an in-memory or Redis store.