Skip to content

Hooks

.use() attaches lifecycle callbacks for logging, tracing and metrics. They observe; they cannot change what the saga does.

ts
defineSaga<Input, Ctx>("checkout")
  .task(/* ... */)
  .use({
    onTaskStart: ({ name }) => logger.debug(`${name} starting`),
    onTaskError: ({ name, error }) => logger.error({ name, err: error }),
    onSagaComplete: ({ name, duration }) => metrics.timing(`saga.${name}`, duration),
  })
  .build(context);

Call .use() as often as you like: each call merges into the last, so a repeated key replaces the earlier handler rather than adding to it.

The eight hooks

HookFiresExtra arguments
onSagaStartbefore the first tasknone (no results)
onSagaCompleteafter the saga settles, success or notduration, error?
onTaskStartbefore each task attempt groupnone
onTaskCompleteafter a task succeedsduration
onTaskErrorafter a task's final attempt failserror
onCompensateStartbefore a compensationnone
onCompensateCompleteafter a compensation succeedsduration
onCompensateErrorafter a compensation throwserror

Every hook receives { name, input, context, results }, except onSagaStart, which has no results yet.

name is the reported name; for a mapped task that is the per-iteration name (notifyWarehouses-eu-west), not the task's base name.

results is Partial<TSagaResult>: mid-run, only the tasks that have completed are in it. In onTaskComplete the finishing task's own result is included.

duration is in milliseconds, measured around that specific unit of work. onSagaComplete's duration covers everything including rollback.

Behavior worth knowing

onSagaComplete fires on failure too. It is "settled", not "succeeded", and fires after compensation has run. error tells the two apart: present exactly when the saga failed, and the same error object execute returns.

ts
onSagaComplete: ({ name, duration, error }) => {
  metrics.timing(`saga.${name}`, duration);
  metrics.increment(`saga.${name}.${error ? "failed" : "ok"}`);
};

onTaskError fires once per task, not once per attempt. Retries are invisible to hooks. If you need per-attempt visibility, log inside execute.

onTaskError fires for optional tasks that fail, even though the saga carries on. The hook does not mean the saga is failing.

Hooks are awaited, but cannot fail the saga. A slow hook slows the saga. A hook that throws is caught, reported, and otherwise ignored, because your telemetry breaking must never roll back real work. Hooks observe a saga; they do not participate in it.

ts
onTaskComplete: async ({ name, duration }) => {
  await metrics.record(name, duration); // may throw; the saga carries on
};

Skipped tasks fire nothing. A task gated out by when produces no hooks at all.

onHookError

Where a throwing hook gets reported. Without it the report goes to console.error, which is not a channel a library should be picking for you.

ts
.use({
  onHookError: ({ name, error }) => logger.warn({ hook: name, error }, "saga hook failed"),
})

It is not one of the eight: it never fires for anything but a hook that threw, and it is not scoped or chained when a saga is inlined. If it throws in turn, the original error falls back to console.error; a reporter must not be the one hook able to break a run.

Inlined sagas

A child saga's hooks come along when it is inlined, narrowed to the tasks that came with it, so a child's onTaskStart never fires for a parent's tasks. The parent's hooks fire for everything, first. The child's onSagaStart and onSagaComplete are dropped: inlining leaves no child saga to start or finish. See Composing sagas.

MIT Licensed