Integrations
Emit state transitions over @adonis-agora/diagnostics (for Telescope), mirror them onto an EventEmitter, and make breaker keys tenant-aware through @adonis-agora/context — all soft-detected and optional.
Resilience plugs into the rest of the ecosystem through event sinks and a context accessor. Every integration is optional and soft-detected through a global Symbol.for(...) slot — nothing is a hard dependency, so each package builds and tests in isolation.
Events
Each state transition is a ResilienceEvent handed to an EventSink:
type ResilienceEventType =
| 'circuit-opened' | 'circuit-closed' | 'circuit-half-open'
| 'short-circuited' | 'failover' | 'timeout' | 'retry'
interface ResilienceEvent {
type: ResilienceEventType
key?: string // the breaker key, when applicable
[extra: string]: unknown // e.g. failover carries target / index / error
}
type EventSink = (event: ResilienceEvent) => voidEvery type in the union is emitted by the core policies today:
- the circuit breaker emits
circuit-opened,circuit-closed,circuit-half-openandshort-circuited; - failover emits
failover(carryingtarget,index,error); - the timeout policy emits
timeout(carryingms, the elapsed budget) when an operation exceeds its deadline; - the retry policy emits
retry(carryingattempt, the 1-based number of the upcoming re-attempt, anderror, the failure that triggered it) before each re-try.
timeout and retry fire only when a sink is wired to the policy, so they cost nothing when nobody is listening. Like the others, they flow through diagnosticsSink() as agora:resilience:timeout / agora:resilience:retry, and through the EventEmitter mirror as resilience.timeout / resilience.retry.
Which policies get the service's sink
A policy captures its sink when it is built, not when it runs. There are exactly two ways it ends up with one:
- You pass
onEventyourself. - It is built inside a named policy factory registered under
policiesinconfig/resilience.ts. TheResilienceServiceinstalls its own sink (diagnostics, plus youreventEmittermirror) for the moment it calls that factory, so everything the factory constructs picks it up.
The consequence is easy to trip over: a Policy object you built yourself and then handed to execute() was constructed outside that window, so it is silent — no diagnostics, no emitter mirror — no matter how it is run.
// config/resilience.ts — built inside the named factory, so it emits.
policies: {
payments: () => wrap(timeout(2_000), retry({ attempts: 3 })),
}
// Emits: the service builds `payments` and injects its sink.
await resilience.execute('payments', op)
// Silent: this timeout was constructed at the call site, outside the service's window.
await resilience.execute(timeout(1_000), op)
// Emits again: pass the sink explicitly when you build the policy yourself.
await resilience.execute(timeout(1_000, { onEvent: diagnosticsSink() }), op)The same rule applies to a module-level const policy = wrap(…) you reuse across calls, and to policies attached with @withResilience — both are built outside any factory, so give them an explicit onEvent if you want them observable.
failover follows the rule too, from the other side: calling the exported failover() function directly emits nowhere unless you pass onEvent, while ResilienceService.failover passes the service's sink for you.
Pass onEvent to any policy, or let the ResilienceService wire the sinks for you. Combine several with combineSinks — a misbehaving sink can't break the others or the policy:
import { circuitBreaker, combineSinks, diagnosticsSink, eventEmitterSink } from '@adonis-agora/resilience'
circuitBreaker({
key: 'payments',
store,
threshold: 5,
cooldownMs: 30_000,
onEvent: combineSinks(diagnosticsSink(), eventEmitterSink(emitter)),
})Diagnostics
diagnosticsSink() republishes each event onto @adonis-agora/diagnostics as agora:resilience:<type> — so Telescope or any onDiagnostic('resilience', …) subscriber can observe circuit and failover activity. It reads the diagnostics emit function through the global slot Symbol.for('@agora/diagnostics:emit'), so it's a no-op when diagnostics isn't installed and free when nothing is subscribed.
import { defineConfig } from '@adonis-agora/resilience'
export default defineConfig({ emit: true }) // default — the service installs diagnosticsSink for youThe ResilienceService wires this automatically from your config; you only call diagnosticsSink() by hand when composing onEvent on a standalone policy. Set emit: false to opt out.
Errors are sanitized before they reach diagnostics
retry's and failover's events carry the raw caught error — useful in-process, since a onEvent callback you register yourself never leaves the app. But diagnosticsSink() is an export boundary: forwarding that raw error as-is could ship an upstream HTTP client's response body, headers, or auth token (attached to a non-standard property like err.response.data) straight into Telescope/diagnostics storage.
So by default, diagnosticsSink() reduces any event's error field to { name, message } before forwarding it — dropping every other own property. Opt into richer detail with sanitizeError, only after confirming the extra fields can't carry secrets/PII:
import { diagnosticsSink } from '@adonis-agora/resilience'
diagnosticsSink({
sanitizeError: (err) => ({
name: (err as Error)?.name,
message: (err as Error)?.message,
code: (err as { code?: string })?.code, // safe to expose — no free-form body/headers
}),
})defaultSanitizeError is also exported, so a custom serializer can extend it instead of restating the base fields.
This is how circuit opens / failovers show up in Telescope: resilience emits the events, diagnostics is the bus, and a Telescope watcher records them with traceId correlation. Resilience itself is not a dashboard — it only emits.
EventEmitter mirror
To react to transitions inside your app — invalidate a cache when a circuit opens, page on repeated failovers — mirror events onto an EventEmitter via the config's eventEmitter:
import { defineConfig } from '@adonis-agora/resilience'
import type { EventEmitterLike } from '@adonis-agora/resilience'
import emitter from '@adonisjs/core/services/emitter'
export default defineConfig({ eventEmitter: emitter as unknown as EventEmitterLike })Event names are derived by resilienceEventName(type) — dots replace dashes, under a resilience. prefix:
// 'circuit-opened' → 'resilience.circuit.opened'
// 'short-circuited' → 'resilience.short.circuited'
// 'failover' → 'resilience.failover'EventEmitterLike is structural — anything with emit(event, ...values) works, so you're not tied to a specific emitter. The service combines the diagnostics sink and the emitter sink with combineSinks when both are present.
Tenant-aware circuit keys
In a multi-tenant app you usually want a circuit per tenant — one tenant's failing provider shouldn't trip the breaker for everyone. tenantSuffix() reads the current tenant id from @adonis-agora/context (via the shared slot Symbol.for('@agora/context:accessor'), returning undefined when context isn't present), so you can fold it into the key:
import { circuitBreaker, tenantSuffix } from '@adonis-agora/resilience'
circuitBreaker({
key: `payments:${tenantSuffix() ?? 'global'}`,
store,
threshold: 5,
cooldownMs: 30_000,
})Because the key carries the tenant, a distributed store keeps each tenant's circuit isolated and fleet-wide at the same time.
Building a custom store
Back the circuit breaker with any engine — reuse the SQL base and a tiny SqlDriver, reuse the pure state machine (computeAdmit / computeRecord), or implement ResilienceStore from scratch — then wire it through the ResilienceService.
Testing
Drive time deterministically with FakeClock so timeouts, backoff and cooldowns are instant and reproducible — and validate a custom store against the shared contract suite.