workflow
Version:
Workflow SDK - Build durable, resilient, and observable workflows
98 lines (71 loc) • 7.8 kB
text/mdx
---
title: registerLifecycleHooks
description: Register global handlers that observe workflow runs completing or failing.
type: reference
summary: Use registerLifecycleHooks to observe run completions and failures from one central place.
prerequisites:
- /docs/foundations/workflows-and-steps
related:
- /docs/observability/lifecycle-hooks
- /docs/api-reference/workflow-api/get-run
---
Registers global workflow lifecycle handlers, invoked by the runtime on the compute that records a run's terminal transition. Use it for best-effort centralized reporting, such as forwarding failed runs to Sentry, without wrapping each workflow body.
Register in the host process that executes workflows, before it handles requests. In **Next.js 16.3.0 and later**, use `instrumentation.ts` as below. Earlier Next.js versions can process a cold Vercel invocation before async registration finishes. See the [framework support table](/docs/observability/lifecycle-hooks#framework-support) for registration locations and a full Sentry example in the guide.
App startup registration does not reach the standalone workflow functions emitted on Vercel by Nuxt 4 / Nitro v2, Astro, Nest, or the CLI `vercel-build-output-api` target. Those targets do not yet support this registration pattern. In Nitro v3, use a static import and synchronous server-plugin registration; Nitro does not await async plugins. Do not register from a workflow file or a step function.
```typescript title="instrumentation.ts" lineNumbers
export async function register() {
if (process.env.NEXT_RUNTIME === "nodejs") {
const { registerLifecycleHooks } = await import("workflow/api");
registerLifecycleHooks({
async onRunCompleted({ run, workflowName }) {
console.log(`Run ${run.runId} (${workflowName}) completed`);
},
async onRunFailed({ run, workflowName, error }) {
console.error(
`Run ${run.runId} (${workflowName}) failed (${error.errorCode})`,
error.cause
);
},
});
}
}
```
Keep the dynamic import inside the `NEXT_RUNTIME === "nodejs"` guard. Next.js also compiles `instrumentation.ts` for the Edge runtime. A top-level static import of `workflow/api` pulls Node.js-only dependencies into that compilation and breaks webpack Edge builds, even if the registration call is guarded.
Restart `next dev` after editing instrumentation handlers: Next.js memoizes registration per process, so edits may not take effect until restart. Other hot-reloading hosts can re-register in the same process; keep the unregister function in process-wide state across reloads, or restart the server.
## API Signature
### Parameters
<TSDoc
definition={`
import { registerLifecycleHooks } from "workflow/api";
export default registerLifecycleHooks;`}
showSections={["parameters"]}
/>
### Returns
Returns a function that unregisters these hooks. Registrations are not deduplicated. Register each hook set once per process, and unregister the previous hooks before registering again during hot reload or module re-evaluation.
## Handlers
Both handlers receive a `workflowName` string and a lazily hydrated [`Run`](/docs/api-reference/workflow-api/get-run) instance. Use the `workflowName` parameter to filter without a backend read; `run.runId` also requires no read. Accessors such as `run.workflowName`, `run.status`, and `run.returnValue` still fetch from the backend when used. In particular, `workflowName` is a string, while `run.workflowName` is a `Promise<string>`. Lazy access defers those reads rather than eliminating them.
Parameters come from the runtime's module copy, which may differ from the handler's copy in a bundled app. Avoid `instanceof` checks against `Run`, SDK errors, or your own bundled error classes. Use `WorkflowRunFailedError.is(error)` (`workflow/errors`), `FatalError.is(error.cause)` (`workflow`), `error.errorCode`, and the cause's `name` or your own serialized discriminator. See [identifying errors across module copies](/docs/observability/lifecycle-hooks#identifying-errors-across-module-copies) for an example.
### `onRunCompleted`
Invoked when a workflow run completes successfully.
| Parameter | Type | Description |
| --- | --- | --- |
| `params.run` | `Run` | The completed run. |
| `params.workflowName` | `string` | The machine-readable workflow identifier, such as `workflow//./src/workflows/order//processOrder`. Available without a backend read. |
### `onRunFailed`
Invoked when a workflow run fails terminally (after any retries).
| Parameter | Type | Description |
| --- | --- | --- |
| `params.run` | `Run` | The failed run. |
| `params.workflowName` | `string` | The machine-readable workflow identifier, such as `workflow//./src/workflows/order//processOrder`. Available without a backend read. |
| `params.error` | `WorkflowRunFailedError` | The persisted failure hydrated for reporting: `error.errorCode` carries the classification (e.g. `USER_ERROR`) and `error.cause` is the hydrated thrown value. |
Unlike `run.returnValue`, `error.cause` defers readable stream I/O until consumption and revives abort signals as persisted snapshots without live subscriptions. Writable streams retain their normal forwarding setup, and writes still reach the failed run's stream or the parent run's stream for a forwarded writable. Custom serialization revives registered classes using the runtime's module copy; plain user-defined Error subclasses without custom serialization revive as generic errors with their name preserved. If hydration fails, the SDK logs the reason and the cause is a generic `Error`, matching `run.returnValue`'s fallback. In `onRunFailed`, `run.returnValue` rejects because the run failed. Use `error.cause` to inspect or report the thrown value instead.
On Vercel, the invocation's `waitUntil` scope drains background stream operations from **`error.cause`**, even after a handler returns or throws. It does not automatically cover pipes from `await run.returnValue` in `onRunCompleted`, or consumption scheduled after the handler returns. Await consumption and other reporting work in your handler. Other hosts may stop detached work when the process freezes or terminates.
<Callout type="warn">
A stream lock left open can keep the drain and lifecycle span pending until the function's maximum duration. Close or release reader and writer locks when finished; cancel a partially consumed reader in `finally`. See the [stream cleanup example](/docs/observability/lifecycle-hooks#stream-lifetime).
</Callout>
## Behavior
- Register at host startup. Handlers run on the host (full Node.js), never inside the workflow VM. Calling `registerLifecycleHooks` from workflow or step code throws.
- Handlers are fire-and-forget. They cannot delay or change the run's outcome, and the runtime logs and swallows a throwing handler. On Vercel, `waitUntil` keeps the invocation alive while handlers finish, subject to the invocation's duration limit. On other hosts, handlers run as detached work and may not complete if the host freezes or terminates the process after the response.
- Delivery is best effort. Each registered handler is invoked at most once by the invocation that writes the terminal event. Callbacks are not retried if they throw or the process dies, so they may never run or may stop before completing. The [event log](/docs/how-it-works/event-sourcing) is the system of record.
- Handlers fire only on the invocation that wrote the terminal event. Transitions recorded outside your app's compute (e.g. a run cancelled from the CLI or dashboard) do not fire handlers.
- You can register multiple hook sets, and handlers run in registration order. Registrations are not deduplicated; unregister a previous hook set before registering it again in the same process.