Events and logging
Dispatching
import { dispatch, events } from '@elvel/events'
class OrderPaid {
constructor(readonly id: number) {}
}
events().listen(OrderPaid, (event) => sendReceipt(event.id))
await dispatch(new OrderPaid(7))A class-based event is its own payload, which is what makes dispatch(new OrderPaid(7)) read the way it does. A string event takes one:
events().listen('order.shipped', (payload) => …)
await dispatch('order.shipped', { at: Date.now() })bun elvel make:event OrderPaid and make:listener SendReceipt write the files; event:list shows what is registered.
Wildcards, and ancestors
events().listen('order.*', (name, payload) => audit(name))
events().listen('*.deleted', (name) => …)A wildcard listener receives the resolved name first, which is how one listener can serve many events. Run against listeners on OrderPaid, order.shipped, order.* and a DomainEvent base class:
dispatch(new OrderPaid(7)) → class:7
dispatch('order.shipped', { at: 1 }) → string:{"at":1}, wildcard:order.shipped
dispatch(new UserRegistered('…')) → ancestorThat last line is the one worth knowing. A listener registered on an ancestor class runs too. A listener can be registered on an interface the event implements; TypeScript erases interfaces so there is nothing at runtime to match, but a base class survives and carries the same meaning — listen(DomainEvent, …) hears everything that extends it.
Note also what did not fire: order.* did not match OrderPaid, because a class event is named from the class. Wildcards match names, so name events with dots if you want to group them.
events().registered()
// { exact: [['OrderPaid', 1], ['order.shipped', 1]], wildcards: [['order.*', 1]] }Asking a question
const answer = await events().until('question')until stops at the first non-null response and returns it. Three listeners returning undefined, 'answer' and 'never reached' give 'answer' — the third never runs.
Returning exactly false from any listener stops propagation, halting or not:
listeners: [() => false, () => push('two')] → ['one']Deferring, and the half-finished write
await events().defer(async () => {
const order = await Order.create(…)
await dispatch(new OrderPaid(order.id))
await takePayment(order) // if this throws, nothing was announced
})Nothing is dispatched until the body finishes:
inside defer, held: []
after defer: ['ran']And a throw drops them rather than dispatching or retrying. That is the point: without it, an order is created, two listeners email the customer, and the third step fails — leaving them told about an order that no longer exists.
defer(body, ['order.paid']) holds that one event and lets everything else through.
until() is never deferred
A halting dispatch is a question and the caller wants the answer. Deferring one would answer null and carry on, which is worse than not deferring it.
Queued listeners
A listener that should not run inside the request can be queued, and then only its name travels — the same constraint jobs and mailables have, for the same reason. setQueue() wires the pusher; the queue page has the rest.
Subscribers
events().subscribe(new OrderSubscriber())One class declaring several listeners, when they belong together.
Testing
const fake = events().fake()
await dispatch(new OrderPaid(7))
fake.assertDispatched(OrderPaid)
fake.assertDispatched(OrderPaid, 1)
fake.assertNotDispatched(OrderRefunded)
fake.assertNothingDispatched()Logging
import { log } from '@elvel/log'
log().info('an info line', { user: 7 })
log().error('order {id} failed for {user}', { id: 'A1', user: 'ada' })Eight levels, in descending severity: emergency, alert, critical, error, warning, notice, info, debug.
{placeholders} are interpolated from the context, and the context is kept as well:
order A1 failed for ada { id: 'A1', user: 'ada' }So a human reads the sentence and a collector still gets the fields. Nothing has to be written twice.
log().withContext({ requestId: 'r-1' }).info('carries context')
log().channel('json').warning('to one channel')Channels
// config/logging.ts
default: env('LOG_CHANNEL', 'stack'),
channels: {
stack: { driver: 'stack', channels: ['console'] },
console: { driver: 'console', level: env('LOG_LEVEL', 'debug') },
json: { driver: 'json', stream: 'stdout', level: env('LOG_LEVEL', 'info') },
single: { driver: 'single', level: 'debug' },
daily: { driver: 'daily', maxFiles: 14 }
}console is coloured and shaped for a person; json writes one object per line for a collector. errorlog writes plain lines to stderr, which is what a container runtime collects — a file inside a container is a file nobody reads before it is destroyed.
A stack fans one record out to several channels, and each keeps its own level:
stack: { driver: 'stack', channels: ['console', 'json'] },
console: { driver: 'console', level: 'debug' },
json: { driver: 'json', level: 'warning' }An info line then reaches the console and not the JSON stream.
This was broken in every alpha up to and including alpha.9
A level is enforced by the Logger, not by the driver beneath it — and a stack was built from bare drivers, so every member's threshold was discarded and only the stack's own applied. Since stack is the default channel, that was the ordinary path: the levels in config/logging.ts were ignored wherever anybody had set one. Writing this page found it.
Following the log
bun elvel log:tail
bun elvel log:tail --level=error --filter=payment --lines=200--path when it is not the single channel's file.
Every write is an event
MessageLogged is dispatched for each record, so a listener can forward to somewhere the drivers do not cover — an APM, an alert — without wrapping every call site.